ci: release through the standards' shared Velopack workflow; publishers fail instead of skip #77
No files matched your search
@@ -1,289 +0,0 @@
|
||||
#Requires -Version 7.4
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Writes release notes for a tag from the Conventional Commit subjects merged since
|
||||
the previous release (K22-REL-02).
|
||||
|
||||
.DESCRIPTION
|
||||
Pull requests squash-merge with their Conventional Commit title as the subject,
|
||||
so the commits between the previous published release and this tag are the
|
||||
changelog. Subjects are grouped by type with breaking changes and security
|
||||
fixes first; subjects that are not Conventional Commits land under "Other
|
||||
changes" so nothing is silently dropped. Each entry links its pull request
|
||||
("(#123)" suffix) or, failing that, its commit. When an issue titled
|
||||
"Release <tag>" exists (the release checklist, K22-REL-12) it is linked too.
|
||||
|
||||
Needs the GitHub CLI authenticated for the repository (GH_TOKEN in Actions).
|
||||
|
||||
.PARAMETER Tag
|
||||
The release tag, e.g. v1.2.3 or v1.2.3-beta.1.
|
||||
|
||||
.PARAMETER Repository
|
||||
owner/name on GitHub.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Where the Markdown notes are written.
|
||||
|
||||
.PARAMETER PreviousTag
|
||||
The tag to compare from. Default: the most recent published release before
|
||||
this tag, or the repository's first commit when there is none.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
|
||||
[string] $Tag,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')]
|
||||
[string] $Repository,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $OutputPath,
|
||||
|
||||
[string] $PreviousTag
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Calls the GitHub REST API through gh and returns the parsed JSON, or $null when
|
||||
the call fails (the caller decides whether that matters).
|
||||
#>
|
||||
function Invoke-GitHubApi
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Path,
|
||||
[switch] $Paginate
|
||||
)
|
||||
|
||||
$arguments = @('api', '-H', 'Accept: application/vnd.github+json')
|
||||
if($Paginate)
|
||||
{
|
||||
$arguments += @('--paginate', '--slurp')
|
||||
}
|
||||
$arguments += $Path
|
||||
|
||||
$output = & gh @arguments 2>$null
|
||||
if($LASTEXITCODE -ne 0)
|
||||
{
|
||||
return $null
|
||||
}
|
||||
return (($output | ForEach-Object { "$_" }) -join "`n") | ConvertFrom-Json -Depth 50
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Finds the tag of the most recent published release that precedes the one being
|
||||
released, so the notes cover exactly what changed since users last got a build.
|
||||
#>
|
||||
function Find-PreviousReleaseTag
|
||||
{
|
||||
param([Parameter(Mandatory)] [string] $CurrentTag)
|
||||
|
||||
$pages = Invoke-GitHubApi -Path "repos/$Repository/releases?per_page=100" -Paginate
|
||||
if($null -eq $pages)
|
||||
{
|
||||
return $null
|
||||
}
|
||||
|
||||
$releases = @($pages | ForEach-Object { $_ }) |
|
||||
Where-Object { -not $_.draft -and $_.tag_name -ne $CurrentTag } |
|
||||
Sort-Object { [DateTime] $_.created_at } -Descending
|
||||
if(@($releases).Count -eq 0)
|
||||
{
|
||||
return $null
|
||||
}
|
||||
return @($releases)[0].tag_name
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Lists the commits between two refs through the compare API: subject, body, sha,
|
||||
and the pull request number when the subject carries a "(#123)" suffix.
|
||||
#>
|
||||
function Get-CommitsBetween
|
||||
{
|
||||
param(
|
||||
[string] $From,
|
||||
[Parameter(Mandatory)] [string] $To
|
||||
)
|
||||
|
||||
if([string]::IsNullOrEmpty($From))
|
||||
{
|
||||
$pages = Invoke-GitHubApi -Path "repos/$Repository/commits?sha=$To&per_page=100" -Paginate
|
||||
$commits = @($pages | ForEach-Object { $_ })
|
||||
}
|
||||
else
|
||||
{
|
||||
$compare = Invoke-GitHubApi -Path "repos/$Repository/compare/$From...$To"
|
||||
if($null -eq $compare)
|
||||
{
|
||||
throw "Cannot compare $From...$To in $Repository."
|
||||
}
|
||||
$commits = @($compare.commits)
|
||||
}
|
||||
|
||||
foreach($commit in $commits)
|
||||
{
|
||||
$lines = $commit.commit.message -split "`r?`n"
|
||||
$subject = $lines[0].Trim()
|
||||
if($subject -match '^Merge (pull request|branch) ')
|
||||
{
|
||||
continue
|
||||
}
|
||||
$pullNumber = $null
|
||||
if($subject -match '\s\(#(\d+)\)$')
|
||||
{
|
||||
$pullNumber = [int] $Matches[1]
|
||||
$subject = $subject -replace '\s\(#\d+\)$', ''
|
||||
}
|
||||
$body = ($lines | Select-Object -Skip 1) -join "`n"
|
||||
[pscustomobject]@{ Sha = $commit.sha; Subject = $subject; Body = $body; PullNumber = $pullNumber }
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Classifies one commit into a notes section from its Conventional Commit header.
|
||||
Breaking changes ("!" or a BREAKING CHANGE footer) and security fixes come
|
||||
first; anything that is not a Conventional Commit goes to "Other changes".
|
||||
#>
|
||||
function Get-NotesSection
|
||||
{
|
||||
param([Parameter(Mandatory)] [pscustomobject] $Commit)
|
||||
|
||||
$header = '^(?<type>[a-z]+)(\((?<scope>[^)]+)\))?(?<bang>!)?: (?<description>.+)$'
|
||||
if($Commit.Subject -notmatch $header)
|
||||
{
|
||||
return [pscustomobject]@{ Section = 'Other changes'; Text = $Commit.Subject }
|
||||
}
|
||||
|
||||
$type = $Matches['type']
|
||||
$scope = $Matches['scope']
|
||||
$description = $Matches['description']
|
||||
$text = if($scope) { "**$scope**: $description" } else { $description }
|
||||
|
||||
if($Matches['bang'] -eq '!' -or $Commit.Body -match '(?m)^BREAKING[ -]CHANGE:')
|
||||
{
|
||||
return [pscustomobject]@{ Section = 'Breaking changes'; Text = $text }
|
||||
}
|
||||
if($type -eq 'security' -or $scope -eq 'security')
|
||||
{
|
||||
return [pscustomobject]@{ Section = 'Security'; Text = $text }
|
||||
}
|
||||
|
||||
$section = switch($type)
|
||||
{
|
||||
'feat' { 'Features' }
|
||||
'fix' { 'Fixes' }
|
||||
'perf' { 'Performance' }
|
||||
'refactor' { 'Refactoring' }
|
||||
'docs' { 'Documentation' }
|
||||
'build' { 'Build and dependencies' }
|
||||
'ci' { 'CI' }
|
||||
'test' { 'Tests' }
|
||||
'chore' { 'Chores' }
|
||||
'style' { 'Style' }
|
||||
'revert' { 'Reverts' }
|
||||
default { 'Other changes' }
|
||||
}
|
||||
return [pscustomobject]@{ Section = $section; Text = $text }
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Finds the release-checklist issue ("Release <tag>") so the notes can link it.
|
||||
#>
|
||||
function Find-ChecklistIssue
|
||||
{
|
||||
param([Parameter(Mandatory)] [string] $CurrentTag)
|
||||
|
||||
$query = [uri]::EscapeDataString("repo:$Repository is:issue in:title `"Release $CurrentTag`"")
|
||||
$result = Invoke-GitHubApi -Path "search/issues?q=$query&per_page=5"
|
||||
if($null -eq $result)
|
||||
{
|
||||
return $null
|
||||
}
|
||||
$match = @($result.items) | Where-Object { $_.title -eq "Release $CurrentTag" } | Select-Object -First 1
|
||||
return $match
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Resolve the range, classify every commit, and write the notes in section ##
|
||||
## order with breaking changes and security fixes first (K22-REL-02). ##
|
||||
################################################################################
|
||||
if([string]::IsNullOrEmpty($PreviousTag))
|
||||
{
|
||||
$PreviousTag = Find-PreviousReleaseTag -CurrentTag $Tag
|
||||
}
|
||||
|
||||
$commits = @(Get-CommitsBetween -From $PreviousTag -To $Tag)
|
||||
$order = @('Breaking changes', 'Security', 'Features', 'Fixes', 'Performance', 'Refactoring',
|
||||
'Documentation', 'Build and dependencies', 'CI', 'Tests', 'Style', 'Chores', 'Reverts', 'Other changes')
|
||||
$grouped = @{}
|
||||
foreach($commit in $commits)
|
||||
{
|
||||
$entry = Get-NotesSection -Commit $commit
|
||||
$link = if($null -ne $commit.PullNumber)
|
||||
{
|
||||
"[#$($commit.PullNumber)](https://github.com/$Repository/pull/$($commit.PullNumber))"
|
||||
}
|
||||
else
|
||||
{
|
||||
"[$($commit.Sha.Substring(0, 7))](https://github.com/$Repository/commit/$($commit.Sha))"
|
||||
}
|
||||
if(-not $grouped.ContainsKey($entry.Section))
|
||||
{
|
||||
$grouped[$entry.Section] = [System.Collections.Generic.List[string]]::new()
|
||||
}
|
||||
$grouped[$entry.Section].Add("- $($entry.Text) ($link)")
|
||||
}
|
||||
|
||||
$notes = [System.Collections.Generic.List[string]]::new()
|
||||
foreach($section in $order)
|
||||
{
|
||||
if(-not $grouped.ContainsKey($section))
|
||||
{
|
||||
continue
|
||||
}
|
||||
$notes.Add("## $section")
|
||||
$notes.Add('')
|
||||
foreach($line in $grouped[$section])
|
||||
{
|
||||
$notes.Add($line)
|
||||
}
|
||||
$notes.Add('')
|
||||
}
|
||||
if($commits.Count -eq 0)
|
||||
{
|
||||
$notes.Add('No changes since the previous release.')
|
||||
$notes.Add('')
|
||||
}
|
||||
|
||||
$checklist = Find-ChecklistIssue -CurrentTag $Tag
|
||||
if($null -ne $checklist)
|
||||
{
|
||||
$notes.Add("Release checklist: [#$($checklist.number)]($($checklist.html_url))")
|
||||
$notes.Add('')
|
||||
}
|
||||
if(-not [string]::IsNullOrEmpty($PreviousTag))
|
||||
{
|
||||
$notes.Add("**Full changelog**: https://github.com/$Repository/compare/$PreviousTag...$Tag")
|
||||
}
|
||||
else
|
||||
{
|
||||
$notes.Add("**Full changelog**: https://github.com/$Repository/commits/$Tag")
|
||||
}
|
||||
$notes.Add('')
|
||||
$notes.Add('Every asset is listed in `SHA256SUMS` and carries a build-provenance attestation; a CycloneDX SBOM per artifact is attached. See "Verifying a release" in the README.')
|
||||
|
||||
$directory = Split-Path -Parent ([System.IO.Path]::GetFullPath($OutputPath))
|
||||
if(-not (Test-Path -LiteralPath $directory))
|
||||
{
|
||||
New-Item -ItemType Directory -Path $directory | Out-Null
|
||||
}
|
||||
[System.IO.File]::WriteAllText($OutputPath, (($notes -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Verbose "Wrote $($commits.Count) commit(s) of notes to $OutputPath"
|
||||
@@ -5,7 +5,7 @@
|
||||
(packaging/chocolatey) at a stable release's CLI zip.
|
||||
|
||||
.DESCRIPTION
|
||||
Both package managers install the same asset, gclo-cli-win-x64.zip, by URL and
|
||||
Both package managers install the same asset, gclo-cli-win-x64-<version>.zip, by URL and
|
||||
SHA-256. This script rewrites the version, URL, checksum, and release-notes link
|
||||
in all three files from one source of truth: the version and the zip's hash
|
||||
(computed from -CliZipPath, or taken from -Sha256). The release workflow runs it
|
||||
@@ -16,7 +16,7 @@
|
||||
The stable semantic version, e.g. 1.2.3 (no leading 'v').
|
||||
|
||||
.PARAMETER CliZipPath
|
||||
Path to the built gclo-cli-win-x64.zip to hash. Either this or -Sha256.
|
||||
Path to the built gclo-cli-win-x64-<version>.zip to hash. Either this or -Sha256.
|
||||
|
||||
.PARAMETER Sha256
|
||||
The zip's SHA-256 (lowercase hex) when it is already known.
|
||||
@@ -76,7 +76,7 @@ if($Sha256 -notmatch '^[0-9a-f]{64}$')
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$url = "https://github.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64.zip"
|
||||
$url = "https://github.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64-$Version.zip"
|
||||
|
||||
################################################################################
|
||||
## Scoop: a real JSON edit, so the file stays valid whatever its layout. ##
|
||||
|
||||
@@ -10,18 +10,8 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The Release workflow calls this one so a tag is gated by exactly the checks
|
||||
# a pull request is: build, coverage, and formatting, with the UI smoke suite
|
||||
# advisory (see ui-tests-advisory) just as it is not a required check on main.
|
||||
# a pull request is: build, coverage, formatting, and the UI end-to-end suite.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ui-tests-advisory:
|
||||
description: >-
|
||||
When true, a failing UI end-to-end job does not fail this workflow. The
|
||||
hosted-runner UIA suite is not yet a required check on main, so a release
|
||||
must not be held to a stricter bar than a merge; a failure is still
|
||||
visible in the job list for follow-up.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -110,16 +100,11 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
ui-tests:
|
||||
# End-to-end FlaUI/UIA tests against the real gclo.exe. Not in the branch
|
||||
# ruleset's required checks yet: hosted-runner UIA is new here, so this job
|
||||
# earns required status by proving itself stable first. Until then a caller
|
||||
# (the Release workflow) can make it advisory so a flaky UI run cannot block
|
||||
# an immutable tag that main itself would have accepted. On push/PR events the
|
||||
# input is unset, so the job fails the workflow exactly as before.
|
||||
# End-to-end FlaUI/UIA tests against the real gclo.exe. A required check on
|
||||
# main since 2026-10-09 (#54), and therefore a blocking gate for releases too.
|
||||
name: ui-tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
continue-on-error: ${{ inputs.ui-tests-advisory == true }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
+185
-291
@@ -1,10 +1,37 @@
|
||||
# Release pipeline, triggered by pushing a tag that starts with 'v'.
|
||||
# Release pipeline, triggered by pushing a tag that starts with 'v'
|
||||
# (docs/RELEASING.md is the runbook).
|
||||
#
|
||||
# v1.2.3 -> stable release -> Velopack channel 'stable'
|
||||
# v1.2.3-beta.1 -> prerelease -> Velopack channel 'dev'
|
||||
#
|
||||
# See docs/RELEASING.md for the full release runbook, including the secrets that
|
||||
# enable the optional NuGet and winget steps.
|
||||
# Stages, each gated on the previous one:
|
||||
# verify - the tag is strict semver and its commit is on main
|
||||
# gates - the full ci workflow, exactly as a pull request runs it (K22-CI-31)
|
||||
# release - the KofTwentyTwo standards' shared Velopack workflow: locked restore,
|
||||
# Release build, tests and coverage gate, publish, vpk pack, SBOMs,
|
||||
# SHA256SUMS, provenance, draft-then-publish GitHub release. The build
|
||||
# runs inside the shared workflow, so the provenance names it as the
|
||||
# builder (SLSA Build L3, K22-CI-30).
|
||||
# packages - stable only: Scoop and Chocolatey manifests pointed at the release,
|
||||
# Chocolatey package pushed, manifests proposed as a PR against main
|
||||
# winget - stable only, once WINGET_PACKAGE_ID is set: manifest update
|
||||
# submitted to microsoft/winget-pkgs
|
||||
# nuget - only when the repository variable NUGET_PUBLISH is 'true'
|
||||
#
|
||||
# A publishing job that cannot publish FAILS; nothing is skipped quietly. winget
|
||||
# is the one explicit opt-in: `wingetcreate update` needs a package that already
|
||||
# exists in microsoft/winget-pkgs, and the first submission is manual and needs a
|
||||
# published stable installer, so the job runs once the WINGET_PACKAGE_ID variable
|
||||
# is set (docs/RELEASING.md) and fails if the token is missing from then on. The
|
||||
# tokens live in the `release` environment, which only v* tags can use:
|
||||
# DEPS_PAT fine-grained token: contents + pull requests (write) on this
|
||||
# repository, so the packaging PR runs the required checks
|
||||
# CHOCO_API_KEY chocolatey.org API key
|
||||
# WINGET_TOKEN fine-grained token that can fork and open pull requests on
|
||||
# public repositories (microsoft/winget-pkgs)
|
||||
# NUGET_API_KEY nuget.org key with push rights for gclo.Engine (NUGET_PUBLISH)
|
||||
# plus the repository variable WINGET_PACKAGE_ID (KofTwentyTwo.gclo, set after the
|
||||
# one-time manual `wingetcreate new`).
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -12,15 +39,7 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Three stages, each gated on the previous:
|
||||
# verify - the tag must be strict semver AND its commit must be on main
|
||||
# gates - the full CI workflow (build, 100% coverage, format; UI smoke advisory)
|
||||
# release - package and publish; only runs when both stages succeeded
|
||||
#
|
||||
# Read-only by default; only the release job gets contents: write, which vpk
|
||||
# and gh need to create the GitHub Release and upload assets to it.
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -29,12 +48,10 @@ concurrency:
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify tag
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout (full history, so main's ancestry can be checked)
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -42,32 +59,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive version from tag
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = $env:GITHUB_REF_NAME
|
||||
# Strict semver 2.0 (major.minor.patch with optional prerelease
|
||||
# identifiers). Build metadata ('+...') is deliberately rejected: it
|
||||
# is not meaningful to NuGet or Velopack asset naming.
|
||||
$semverPattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$'
|
||||
if ($tag -notmatch $semverPattern) {
|
||||
Write-Error "Tag '$tag' is not a valid release tag. Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix, e.g. v1.2.3 or v1.2.3-beta.1."
|
||||
exit 1
|
||||
}
|
||||
$version = $tag.Substring(1)
|
||||
$prerelease = $version.Contains('-')
|
||||
$channel = if ($prerelease) { 'dev' } else { 'stable' }
|
||||
Write-Host "Tag: $tag | Version: $version | Prerelease: $prerelease | Velopack channel: $channel"
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "version=$version"
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "prerelease=$($prerelease.ToString().ToLower())"
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "channel=$channel"
|
||||
|
||||
# Releases are cut from main (docs/RELEASING.md). A tag anywhere else -
|
||||
# a feature branch, an unmerged dev commit, a detached experiment - would
|
||||
# otherwise publish an immutable release that never passed main's gates.
|
||||
# To exercise the failure path, push a 'v*' tag on a commit that is not
|
||||
# on main: this step fails and nothing downstream runs.
|
||||
# Releases are cut from main. A tag anywhere else (a feature branch, a
|
||||
# detached experiment) would otherwise publish an immutable release that
|
||||
# never passed main's gates. The shared workflow validates the tag's
|
||||
# format itself.
|
||||
- name: Require the tagged commit to be on main
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -81,274 +76,91 @@ jobs:
|
||||
fi
|
||||
|
||||
# The same gates a pull request must pass (K22-CI-31): zero-warning x64 build,
|
||||
# 100% line coverage on Engine/ViewModels/Cli, and formatting; the security
|
||||
# scans and CodeQL run on the tagged commit when it reached main. If any of those fails, this
|
||||
# job fails and release is skipped. The FlaUI UI smoke suite runs too but is
|
||||
# advisory here, exactly as it is not a required check on main: hosted-runner UI
|
||||
# automation is not yet proven stable enough to hold an immutable tag hostage.
|
||||
# Documented exception per #44; flip ui-tests-advisory to false (and add the job
|
||||
# to main's required checks) once the suite has earned it.
|
||||
# 100% line coverage, formatting, and the FlaUI end-to-end suite.
|
||||
gates:
|
||||
name: Gates
|
||||
needs: verify
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
ui-tests-advisory: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
release:
|
||||
name: Build, package, and publish
|
||||
name: Release
|
||||
needs: [verify, gates]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@e8ab30f8c60df437440ffd786c87678207246f5c # main 2026-10-09 (pre-1.0 draft, standards#5)
|
||||
permissions:
|
||||
contents: write
|
||||
# Build provenance attestations for the published assets.
|
||||
id-token: write
|
||||
attestations: write
|
||||
contents: write # create and publish the GitHub release
|
||||
id-token: write # sign attestations; Azure OIDC when signing
|
||||
attestations: write # store attestations
|
||||
with:
|
||||
solution: gclo.slnx
|
||||
app-project: gclo/gclo.csproj
|
||||
app-id: gclo
|
||||
main-exe: gclo.exe
|
||||
icon: gclo/Assets/Brand/gclo.ico
|
||||
platform: x64
|
||||
runtime: win-x64
|
||||
coverage-packages: gclo.Engine,gclo.ViewModels,gclo
|
||||
coverage-minimum: 100
|
||||
test-projects: |
|
||||
gclo.Engine.Tests
|
||||
gclo.Cli.Tests
|
||||
cli-project: gclo.Cli/gclo.Cli.csproj
|
||||
sign: false # standards exception EX-0002 (#57): true once the Azure Trusted Signing identity is validated
|
||||
|
||||
# Scoop and Chocolatey (#9), stable releases only. The repository is its own
|
||||
# Scoop bucket (bucket/gclo.json) and ships the Chocolatey package source
|
||||
# (packaging/chocolatey). Both are pointed at this release's CLI zip; the
|
||||
# Chocolatey package is pushed, and the updated manifests are proposed as a
|
||||
# pull request against main, where Scoop reads bucket/gclo.json from.
|
||||
packages:
|
||||
name: Scoop and Chocolatey
|
||||
needs: release
|
||||
if: needs.release.outputs.prerelease == 'false'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
REPO_URL: https://github.com/KofTwentyTwo/gclo
|
||||
# The verify job's outputs, as plain environment variables: run: scripts
|
||||
# read these instead of interpolating ${{ }} expressions (K22-CI-12).
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
CHANNEL: ${{ needs.verify.outputs.channel }}
|
||||
# SBOM generator (CycloneDX for .NET), pinned like the other release tooling.
|
||||
CYCLONEDX_VERSION: 6.2.0
|
||||
# Pinned release tooling. vpk builds the installer and packages users
|
||||
# receive and wingetcreate is a binary fetched at run time; neither may
|
||||
# float. Bump deliberately, together with the Velopack package reference.
|
||||
VPK_VERSION: 1.2.161
|
||||
WINGETCREATE_VERSION: v1.12.13.0
|
||||
WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea
|
||||
# The publishing secrets (NUGET_API_KEY, WINGET_TOKEN) are NOT job-level env:
|
||||
# only the two steps that publish receive them, so nothing executed by the
|
||||
# build, the tests, vpk, or wingetcreate's download can read them.
|
||||
K22_VERSION: ${{ needs.release.outputs.version }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# No restore cache here (K22-CI-15): a release never builds from a cache a
|
||||
# pull request run could have written.
|
||||
- name: Set up .NET SDK
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
global-json-file: global.json
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (a) CLI: self-contained single-file publish, zipped as a release asset.
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Publish CLI (win-x64, single file)
|
||||
run: dotnet publish gclo.Cli -c Release -r win-x64 --self-contained -p:Version=$env:VERSION -p:PublishSingleFile=true -p:RestoreLockedMode=true -o publish/cli
|
||||
|
||||
# Released artifacts carry the license (K22-REPO-01).
|
||||
- name: Zip CLI
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path artifacts | Out-Null
|
||||
Copy-Item LICENSE publish/cli/LICENSE
|
||||
Compress-Archive -Path publish/cli/* -DestinationPath artifacts/gclo-cli-win-x64.zip
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (b) WinUI app: unpackaged (no MSIX) self-contained publish, then
|
||||
# packaged with Velopack. vpk upload creates the GitHub Release itself
|
||||
# and attaches the Setup, portable, and delta assets to it.
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Publish app for Velopack (unpackaged win-x64)
|
||||
run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=$env:VERSION -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -p:RestoreLockedMode=true -o publish/app
|
||||
|
||||
- name: Add the license to the app payload
|
||||
shell: pwsh
|
||||
run: Copy-Item LICENSE publish/app/LICENSE
|
||||
|
||||
- name: Install Velopack CLI
|
||||
run: dotnet tool install -g vpk --version $env:VPK_VERSION
|
||||
|
||||
- name: Download previous Velopack release (delta baseline)
|
||||
# The very first release on a channel has nothing to download; that is
|
||||
# fine - vpk pack simply produces no delta package - so a failure here
|
||||
# must not fail the release.
|
||||
continue-on-error: true
|
||||
# The secrets context cannot be read in `if`; this step is the only one
|
||||
# besides the publishers that sees the values, and it never prints them.
|
||||
- name: Require the publishing secrets
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$preFlag = @()
|
||||
if ($env:PRERELEASE -eq 'true') { $preFlag = @('--pre') }
|
||||
vpk download github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel $env:CHANNEL @preFlag
|
||||
|
||||
- name: Velopack pack
|
||||
run: vpk pack --packId gclo --packVersion $env:VERSION --packDir publish/app --mainExe gclo.exe --channel $env:CHANNEL --icon gclo/Assets/Brand/gclo.ico
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# SBOM (K22-REL-06): one CycloneDX document per shipped artifact, from the
|
||||
# resolved dependency graph of the project that produced it. File names
|
||||
# carry the version (K22-REL-03).
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Install CycloneDX
|
||||
run: dotnet tool install -g CycloneDX --version $env:CYCLONEDX_VERSION
|
||||
|
||||
- name: Generate SBOMs
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path artifacts/sbom | Out-Null
|
||||
dotnet CycloneDX gclo.Cli/gclo.Cli.csproj --output artifacts/sbom --filename "gclo-cli-win-x64-$env:VERSION.cdx.json" --output-format Json --spec-version 1.6 --set-name gclo-cli --set-version $env:VERSION --set-type Application --exclude-test-projects --disable-package-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet CycloneDX gclo/gclo.csproj --output artifacts/sbom --filename "gclo-app-$env:VERSION.cdx.json" --output-format Json --spec-version 1.6 --set-name gclo --set-version $env:VERSION --set-type Application --exclude-test-projects --disable-package-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Get-ChildItem artifacts/sbom
|
||||
|
||||
# SHA256SUMS (K22-REL-04): one line per asset ('<sha256> *<name>'), so users
|
||||
# and the Scoop autoupdate can verify downloads without computing hashes by
|
||||
# hand. Fixed-name channel assets (the update feed) are listed too (K22-REL-03).
|
||||
- name: Write SHA256SUMS
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(Get-Item artifacts/gclo-cli-win-x64.zip) + @(Get-ChildItem artifacts/sbom -File) + @(Get-ChildItem Releases -File | Where-Object { $_.Extension -in '.exe', '.zip', '.nupkg', '.json' })
|
||||
$lines = $files | ForEach-Object { "{0} *{1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name }
|
||||
[System.IO.File]::WriteAllText("artifacts/SHA256SUMS", ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||
Get-Content artifacts/SHA256SUMS
|
||||
|
||||
# Provenance: a signed statement that THIS workflow run built these exact
|
||||
# bytes, verifiable with `gh attestation verify <file> -R KofTwentyTwo/gclo`.
|
||||
# Not a code signature (K22 exception EX-0002, #57); it makes a release
|
||||
# attributable to the workflow rather than only to whoever holds the account.
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/gclo-cli-win-x64.zip
|
||||
artifacts/SHA256SUMS
|
||||
artifacts/sbom/*.cdx.json
|
||||
Releases/*.exe
|
||||
Releases/*.zip
|
||||
Releases/*.nupkg
|
||||
|
||||
- name: Attest the CLI SBOM
|
||||
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
|
||||
with:
|
||||
subject-path: artifacts/gclo-cli-win-x64.zip
|
||||
sbom-path: artifacts/sbom/gclo-cli-win-x64-${{ needs.verify.outputs.version }}.cdx.json
|
||||
|
||||
- name: Attest the app SBOM
|
||||
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
Releases/*.exe
|
||||
Releases/*.zip
|
||||
Releases/*.nupkg
|
||||
sbom-path: artifacts/sbom/gclo-app-${{ needs.verify.outputs.version }}.cdx.json
|
||||
|
||||
# This repo has immutable releases: assets freeze the moment a release is
|
||||
# PUBLISHED. So vpk creates a DRAFT (no --publish), every asset and the
|
||||
# changelog land while it is still mutable, and publishing happens last.
|
||||
- name: Velopack upload (creates a draft GitHub Release)
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$preFlag = @()
|
||||
if ($env:PRERELEASE -eq 'true') { $preFlag = @('--pre') }
|
||||
vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel $env:CHANNEL --releaseName "gclo $env:VERSION" --tag $env:GITHUB_REF_NAME @preFlag
|
||||
|
||||
- name: Attach the CLI zip, checksums, and SBOMs to the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip artifacts/SHA256SUMS (Get-ChildItem artifacts/sbom -File).FullName --clobber
|
||||
|
||||
# Release notes from the Conventional Commit titles merged since the previous
|
||||
# release, breaking changes and security fixes first (K22-REL-02).
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
./.github/scripts/New-ReleaseNotes.ps1 -Tag $env:GITHUB_REF_NAME -Repository $env:REPOSITORY -OutputPath notes.md
|
||||
Get-Content notes.md
|
||||
gh release edit $env:GITHUB_REF_NAME --notes-file notes.md
|
||||
|
||||
- name: Publish the completed release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release edit $env:GITHUB_REF_NAME --draft=false
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (c) NuGet: pack gclo.Engine and push to nuget.org - only when the
|
||||
# NUGET_API_KEY secret is configured on the repository.
|
||||
# ----------------------------------------------------------------------
|
||||
# ContinuousIntegrationBuild normalizes the embedded source paths so the
|
||||
# package is deterministic and SourceLink step-through works; the .snupkg
|
||||
# beside it carries the symbols.
|
||||
- name: Pack gclo.Engine
|
||||
run: dotnet pack gclo.Engine -c Release -p:Version=$env:VERSION -p:ContinuousIntegrationBuild=true -p:RestoreLockedMode=true -o artifacts/nuget
|
||||
|
||||
# The secrets context is not available in a step's `if`, so one step turns
|
||||
# "is it configured?" into outputs; it is the only step besides the two
|
||||
# publishers that sees the values, and it never prints them.
|
||||
- name: Check which optional publishing secrets are configured
|
||||
id: secrets
|
||||
shell: pwsh
|
||||
env:
|
||||
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
DEPS_PAT: ${{ secrets.DEPS_PAT }}
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
|
||||
run: |
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value ("nuget=" + (-not [string]::IsNullOrEmpty($env:NUGET_API_KEY)).ToString().ToLower())
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value ("winget=" + (-not [string]::IsNullOrEmpty($env:WINGET_TOKEN)).ToString().ToLower())
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value ("choco=" + (-not [string]::IsNullOrEmpty($env:CHOCO_API_KEY)).ToString().ToLower())
|
||||
$missing = @('DEPS_PAT', 'CHOCO_API_KEY') | Where-Object { [string]::IsNullOrEmpty((Get-Item -Path "env:$_" -ErrorAction SilentlyContinue).Value) }
|
||||
if ($missing)
|
||||
{
|
||||
Write-Output "::error::Missing in the release environment: $($missing -join ', '). Add them (docs/RELEASING.md) and re-run this job."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Push gclo.Engine to nuget.org
|
||||
if: steps.secrets.outputs.nuget == 'true'
|
||||
env:
|
||||
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||
run: dotnet nuget push "artifacts/nuget/*.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (d) winget: update the KofTwentyTwo.gclo manifest for STABLE releases
|
||||
# only, and only when the WINGET_TOKEN secret is configured.
|
||||
#
|
||||
# NOTE: the FIRST winget submission cannot be automated with `update` -
|
||||
# the package must already exist in microsoft/winget-pkgs. Bootstrap it
|
||||
# once manually with `wingetcreate new <Setup.exe url>` (see
|
||||
# docs/RELEASING.md), then this step keeps it current.
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Submit winget manifest update
|
||||
if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.winget == 'true'
|
||||
- name: Download the CLI zip from the published release
|
||||
shell: pwsh
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# A pinned release asset, verified against its committed SHA-256, instead
|
||||
# of whatever https://aka.ms/wingetcreate/latest redirects to today.
|
||||
$url = "https://github.com/microsoft/winget-create/releases/download/$env:WINGETCREATE_VERSION/wingetcreate.exe"
|
||||
Invoke-WebRequest $url -OutFile wingetcreate.exe
|
||||
$actual = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $env:WINGETCREATE_SHA256) {
|
||||
Write-Error "wingetcreate.exe hash mismatch: expected $env:WINGETCREATE_SHA256, got $actual"
|
||||
exit 1
|
||||
}
|
||||
# Velopack names the installer '<packId>-<channel>-Setup.exe'.
|
||||
$setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe"
|
||||
.\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version $env:VERSION --submit --token $env:WINGET_TOKEN
|
||||
New-Item -ItemType Directory -Force -Path artifacts | Out-Null
|
||||
gh release download $env:TAG --pattern "gclo-cli-win-x64-$env:K22_VERSION.zip" --dir artifacts
|
||||
Get-ChildItem artifacts
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (e) Scoop + Chocolatey (#9), STABLE releases only. The repository is its
|
||||
# own Scoop bucket (bucket/gclo.json) and ships the Chocolatey package
|
||||
# source (packaging/chocolatey). Both are pointed at this release's CLI
|
||||
# zip; the Chocolatey package is pushed when CHOCO_API_KEY is configured,
|
||||
# and the updated manifests are proposed as a pull request against main,
|
||||
# where Scoop reads bucket/gclo.json from. (With only GITHUB_TOKEN the PR
|
||||
# triggers no checks; DEPS_PAT fixes that, see #7.)
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Point the Scoop and Chocolatey manifests at this release
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
id: manifests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Update-PackageManifests.ps1 -Version $env:VERSION -CliZipPath artifacts/gclo-cli-win-x64.zip
|
||||
run: ./.github/scripts/Update-PackageManifests.ps1 -Version $env:K22_VERSION -CliZipPath "artifacts/gclo-cli-win-x64-$env:K22_VERSION.zip"
|
||||
|
||||
- name: Pack the Chocolatey package
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts/chocolatey | Out-Null
|
||||
@@ -356,31 +168,29 @@ jobs:
|
||||
Get-ChildItem artifacts/chocolatey
|
||||
|
||||
- name: Push the Chocolatey package
|
||||
if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.choco == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
|
||||
run: choco push (Get-ChildItem artifacts/chocolatey/*.nupkg | Select-Object -First 1).FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
|
||||
|
||||
- name: Open a PR with the updated package manifests
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.DEPS_PAT || github.token }}
|
||||
token: ${{ secrets.DEPS_PAT }}
|
||||
base: main
|
||||
branch: packaging/v${{ needs.verify.outputs.version }}
|
||||
branch: packaging/v${{ needs.release.outputs.version }}
|
||||
delete-branch: true
|
||||
commit-message: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}'
|
||||
title: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}'
|
||||
commit-message: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.release.outputs.version }}'
|
||||
title: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.release.outputs.version }}'
|
||||
body: |
|
||||
Generated by the Release workflow for tag `${{ github.ref_name }}`.
|
||||
|
||||
- `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at
|
||||
`gclo-cli-win-x64.zip` of v${{ needs.verify.outputs.version }}
|
||||
`gclo-cli-win-x64-${{ needs.release.outputs.version }}.zip`
|
||||
(SHA-256 `${{ steps.manifests.outputs.sha256 }}`).
|
||||
- `scoop update gclo` picks this up as soon as this PR is merged (Scoop
|
||||
reads the manifest from `main`); the Chocolatey package
|
||||
was ${{ steps.secrets.outputs.choco == 'true' && 'pushed to chocolatey.org' || 'packed but not pushed (CHOCO_API_KEY not configured)' }}.
|
||||
reads the manifest from `main`); the Chocolatey package was pushed to
|
||||
chocolatey.org.
|
||||
|
||||
Relates to #9.
|
||||
labels: |
|
||||
@@ -389,3 +199,87 @@ jobs:
|
||||
bucket/gclo.json
|
||||
packaging/chocolatey/gclo.nuspec
|
||||
packaging/chocolatey/tools/chocolateyinstall.ps1
|
||||
|
||||
# winget, stable releases only, once the package exists. `wingetcreate update`
|
||||
# can only update a package that already exists in microsoft/winget-pkgs; the
|
||||
# first submission is manual (docs/RELEASING.md), after which WINGET_PACKAGE_ID
|
||||
# is set and this job keeps the manifest current (re-run it on the release that
|
||||
# was bootstrapped). The job lives here, not in the shared workflow, because its
|
||||
# token belongs to this repository's `release` environment.
|
||||
winget:
|
||||
name: winget
|
||||
needs: release
|
||||
if: needs.release.outputs.prerelease == 'false' && vars.WINGET_PACKAGE_ID != ''
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
environment: release
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Submit winget manifest update
|
||||
shell: pwsh
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
WINGET_PACKAGE_ID: ${{ vars.WINGET_PACKAGE_ID }}
|
||||
K22_VERSION: ${{ needs.release.outputs.version }}
|
||||
SETUP_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ needs.release.outputs.setup }}
|
||||
WINGETCREATE_VERSION: v1.12.13.0
|
||||
WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea
|
||||
run: |
|
||||
if ([string]::IsNullOrEmpty($env:WINGET_TOKEN))
|
||||
{
|
||||
Write-Output '::error::WINGET_PACKAGE_ID is set but WINGET_TOKEN is missing from the release environment. Add it and re-run this job.'
|
||||
exit 1
|
||||
}
|
||||
# A pinned release asset, verified against its committed SHA-256, instead
|
||||
# of whatever https://aka.ms/wingetcreate/latest redirects to today.
|
||||
$wingetcreate = Join-Path $env:RUNNER_TEMP 'wingetcreate.exe'
|
||||
Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/$env:WINGETCREATE_VERSION/wingetcreate.exe" -OutFile $wingetcreate
|
||||
$actual = (Get-FileHash -LiteralPath $wingetcreate -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $env:WINGETCREATE_SHA256)
|
||||
{
|
||||
Write-Output "::error::wingetcreate.exe hash mismatch: expected $env:WINGETCREATE_SHA256, got $actual"
|
||||
exit 1
|
||||
}
|
||||
& $wingetcreate update $env:WINGET_PACKAGE_ID --urls $env:SETUP_URL --version $env:K22_VERSION --submit --token $env:WINGET_TOKEN
|
||||
|
||||
# gclo.Engine on nuget.org: opt-in through the repository variable NUGET_PUBLISH
|
||||
# (the library has no published consumers yet). Once opted in, a missing key fails.
|
||||
nuget:
|
||||
name: NuGet (gclo.Engine)
|
||||
needs: release
|
||||
if: vars.NUGET_PUBLISH == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
K22_VERSION: ${{ needs.release.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET SDK
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
global-json-file: global.json
|
||||
|
||||
# ContinuousIntegrationBuild normalizes the embedded source paths so the
|
||||
# package is deterministic and SourceLink step-through works.
|
||||
- name: Pack gclo.Engine
|
||||
shell: pwsh
|
||||
run: dotnet pack gclo.Engine -c Release "-p:Version=$env:K22_VERSION" -p:ContinuousIntegrationBuild=true -p:RestoreLockedMode=true -o artifacts/nuget
|
||||
|
||||
- name: Push gclo.Engine to nuget.org
|
||||
shell: pwsh
|
||||
env:
|
||||
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||
run: |
|
||||
if ([string]::IsNullOrEmpty($env:NUGET_API_KEY))
|
||||
{
|
||||
Write-Output '::error::NUGET_PUBLISH is true but NUGET_API_KEY is not in the release environment.'
|
||||
exit 1
|
||||
}
|
||||
dotnet nuget push "artifacts/nuget/*.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
+1
-1
@@ -101,7 +101,7 @@ pre-commit install --hook-type pre-commit --hook-type commit-msg
|
||||
| `codeql / analyze (csharp)`, `codeql / analyze (actions)` | No high-severity static analysis findings |
|
||||
| `build-test` | Zero-warning x64 build, both unit suites, 100% coverage gate |
|
||||
| `format` | `dotnet format --verify-no-changes --severity warn` and PSScriptAnalyzer |
|
||||
| `ui-tests` | FlaUI end-to-end tests against the real `gclo.exe` (advisory until it is made required, #54) |
|
||||
| `ui-tests` | FlaUI end-to-end tests against the real `gclo.exe`, required since 2026-10-09 (#54) |
|
||||
|
||||
## Sign-off (Developer Certificate of Origin)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ All builds are published on the [**GitHub Releases** page](https://github.com/Ko
|
||||
|
||||
**Desktop app (GUI)** — download and run `gclo-stable-Setup.exe`. It installs per-user (no admin rights needed) and updates itself in-app via **Help > Check for updates**. A portable build (`gclo-stable-Portable.zip`) is also attached to each release; portable builds do not self-update.
|
||||
|
||||
**CLI** — download `gclo-cli-win-x64.zip` and unzip it anywhere. It is a self-contained single `gclo.exe` — no .NET runtime required.
|
||||
**CLI** — download `gclo-cli-win-x64-<version>.zip` and unzip it anywhere. It is a self-contained single `gclo.exe` — no .NET runtime required.
|
||||
|
||||
Plain version tags (`v1.2.3`) are **stable** releases; pre-release tags (`v1.2.3-beta.1`) publish to the **dev** channel, and an installed app only ever updates within its own channel.
|
||||
|
||||
@@ -50,18 +50,18 @@ choco install gclo
|
||||
|
||||
## Verifying a release
|
||||
|
||||
Every release asset is listed in `SHA256SUMS`, carries a build-provenance attestation, and ships with a CycloneDX SBOM (`gclo-cli-win-x64-<version>.cdx.json`, `gclo-app-<version>.cdx.json`):
|
||||
Every release asset is listed in `SHA256SUMS`, carries a build-provenance attestation, and ships with a CycloneDX SBOM (`gclo-<version>.cdx.json` for the app, `gclo-cli-<version>.cdx.json` for the CLI). Releases are built by the [KofTwentyTwo standards' shared release workflow](https://github.com/KofTwentyTwo/standards/blob/main/.github/workflows/release-velopack.yml), so the provenance names that workflow as the builder:
|
||||
|
||||
```powershell
|
||||
# 1. Integrity: the file matches the published checksum
|
||||
(Get-FileHash .\gclo-stable-Setup.exe -Algorithm SHA256).Hash # compare with the line in SHA256SUMS
|
||||
sha256sum --check --ignore-missing SHA256SUMS # Linux / macOS
|
||||
|
||||
# 2. Origin: built by this repository's release workflow from the tagged commit
|
||||
gh attestation verify .\gclo-stable-Setup.exe --repo KofTwentyTwo/gclo
|
||||
# 2. Origin: built from this repository's tagged commit by the shared release workflow
|
||||
gh attestation verify .\gclo-stable-Setup.exe --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards
|
||||
|
||||
# 3. Dependencies: the SBOM is attested too
|
||||
gh attestation verify .\gclo-cli-win-x64.zip --repo KofTwentyTwo/gclo --predicate-type https://cyclonedx.org/bom
|
||||
gh attestation verify .\gclo-cli-win-x64-<version>.zip --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards --predicate-type https://cyclonedx.org/bom
|
||||
```
|
||||
|
||||
The binaries are not yet Authenticode-signed (standards exception [EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002), [#57](https://github.com/KofTwentyTwo/gclo/issues/57)), so step 2 is the origin check until `Get-AuthenticodeSignature` can be.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ MAJOR's last MINOR receives security fixes for 6 months. There are no maintenanc
|
||||
branches beyond that.
|
||||
|
||||
Installed desktop builds update in place via **Help → Check for updates**; the CLI is
|
||||
updated by downloading the latest `gclo-cli-win-x64.zip` from the
|
||||
updated by downloading the latest `gclo-cli-win-x64-<version>.zip` from the
|
||||
[releases page](https://github.com/KofTwentyTwo/gclo/releases) or through Scoop.
|
||||
|
||||
## Published vulnerabilities
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64.zip",
|
||||
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64-$version.zip",
|
||||
"hash": {
|
||||
"url": "$baseurl/SHA256SUMS"
|
||||
}
|
||||
|
||||
+95
-105
@@ -1,9 +1,12 @@
|
||||
# Releasing gclo
|
||||
|
||||
Releases are fully automated by [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
Pushing a tag that starts with `v` builds, tests, packages, and publishes
|
||||
everything. This document explains how versioning works, what gets published
|
||||
where, and how to cut a release start to finish.
|
||||
Releases are automated by [`.github/workflows/release.yml`](../.github/workflows/release.yml),
|
||||
which calls the KofTwentyTwo standards' shared
|
||||
[`release-velopack.yml`](https://github.com/KofTwentyTwo/standards/blob/main/.github/workflows/release-velopack.yml)
|
||||
for the build (ADR [0007](adr/0007-shared-velopack-release-workflow.md)). Pushing a
|
||||
tag that starts with `v` builds, tests, packages, and publishes everything. This
|
||||
document explains how versioning works, what gets published where, what must be
|
||||
configured for a release to succeed, and how to cut one start to finish.
|
||||
|
||||
## Versioning and channels
|
||||
|
||||
@@ -32,41 +35,49 @@ its own channel:
|
||||
- Users who installed from a **dev** release (`gclo-dev-Setup.exe`) get every
|
||||
prerelease you tag.
|
||||
|
||||
Velopack asset names include the channel. A stable release `v1.2.3` carries:
|
||||
A stable release `v1.2.3` carries:
|
||||
|
||||
- `gclo-stable-Setup.exe` — the installer
|
||||
- `gclo-stable-Portable.zip` — portable build
|
||||
- `gclo-1.2.3-stable-full.nupkg` — full update package
|
||||
- `gclo-1.2.3-stable-delta.nupkg` — delta from the previous stable release
|
||||
(absent on the first release of a channel)
|
||||
- `gclo-cli-win-x64.zip` — self-contained single-file CLI
|
||||
- `releases.stable.json` and `assets.stable.json` — the Velopack feed, limited to
|
||||
this build
|
||||
- `gclo-cli-win-x64-1.2.3.zip` — self-contained single-file CLI, with the LICENSE
|
||||
- `gclo-1.2.3.cdx.json`, `gclo-cli-1.2.3.cdx.json` — CycloneDX SBOMs
|
||||
- `SHA256SUMS` — one line per asset
|
||||
|
||||
The workflow runs `vpk download github` before packing so deltas can be
|
||||
generated against the previous release on the same channel; on the very first
|
||||
release of a channel that step finds nothing and is allowed to fail — you just
|
||||
get a release without a delta package, which is normal.
|
||||
The shared workflow downloads the previous release on the same channel before
|
||||
packing so deltas can be generated; on the very first release of a channel that
|
||||
finds nothing, and the release simply has no delta package.
|
||||
|
||||
## What gets published, and which secrets enable it
|
||||
## What gets published, and what it needs
|
||||
|
||||
| Target | Condition | Secret |
|
||||
| ------ | --------- | ------ |
|
||||
| GitHub Release (Setup, portable, full/delta packages, CLI zip) | always | none — the built-in `GITHUB_TOKEN` is enough |
|
||||
| nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` |
|
||||
| winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) |
|
||||
| `SHA256SUMS` and CycloneDX SBOMs on the GitHub Release | always | none |
|
||||
| Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `main` with the updated manifest; merge it and `scoop update gclo` sees the release |
|
||||
| Chocolatey (`gclo` on chocolatey.org) | stable releases only, and `CHOCO_API_KEY` secret is set | `CHOCO_API_KEY` — an API key from your chocolatey.org account; the first push creates the package (it then goes through Chocolatey moderation) |
|
||||
Everything a publishing job needs lives in the **`release` environment**
|
||||
(Settings → Environments → `release`, deployment rule: tags matching `v*`) or in
|
||||
repository variables. **A job whose token is missing fails** — a green run means
|
||||
every enabled channel was published. A failed publisher can be re-run on its own
|
||||
after the token is added ("Re-run failed jobs"); the release itself is not rebuilt.
|
||||
winget is the one channel switched on explicitly (`WINGET_PACKAGE_ID`), because
|
||||
its first submission cannot be automated; see below.
|
||||
|
||||
If a secret is not configured, the corresponding step is skipped cleanly — the
|
||||
release still succeeds. Configure secrets under
|
||||
**Settings → Secrets and variables → Actions**.
|
||||
| Job | Runs for | Needs | Publishes |
|
||||
| --- | -------- | ----- | --------- |
|
||||
| `release` (shared workflow) | every tag | nothing beyond `GITHUB_TOKEN` | the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, `SHA256SUMS`, provenance and SBOM attestations |
|
||||
| `packages` | stable only | `DEPS_PAT` (fine-grained token: contents and pull requests, write, this repository, ≤ 90 days), `CHOCO_API_KEY` (chocolatey.org) | the Chocolatey package pushed to chocolatey.org, and a `build(packaging): …` pull request against `main` with `bucket/gclo.json` and `packaging/chocolatey` pointed at the release |
|
||||
| `winget` | stable only, once the repository variable `WINGET_PACKAGE_ID` is set (`KofTwentyTwo.gclo`, after the one-time manual submission below) | `WINGET_TOKEN` (fine-grained token that can fork and open pull requests on public repositories, ≤ 90 days); missing → the job fails | a manifest update PR on [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) |
|
||||
| `nuget` | only when the repository variable `NUGET_PUBLISH` is `true` | `NUGET_API_KEY` (nuget.org, push rights for `gclo.Engine`) | `gclo.Engine` on nuget.org |
|
||||
|
||||
Why `DEPS_PAT`: a pull request opened with the default `GITHUB_TOKEN` triggers no
|
||||
status checks, so the packaging PR could never satisfy `main`'s required checks.
|
||||
|
||||
## First winget submission (one-time, manual)
|
||||
|
||||
`wingetcreate update` can only update a package that already exists in
|
||||
[microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs). The first
|
||||
submission must be done by hand, once, after your first stable GitHub release
|
||||
exists:
|
||||
submission is done by hand, once, after the first stable GitHub release exists
|
||||
(until `WINGET_PACKAGE_ID` is set the `winget` job does not run):
|
||||
|
||||
```powershell
|
||||
# From any Windows machine:
|
||||
@@ -74,31 +85,29 @@ Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
|
||||
# Interactive: prompts for package id (use KofTwentyTwo.gclo), publisher,
|
||||
# license, description, etc., then opens a PR against microsoft/winget-pkgs.
|
||||
.\wingetcreate.exe new https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-stable-Setup.exe --token <your GitHub PAT>
|
||||
.\wingetcreate.exe new https://github.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --token <your GitHub PAT>
|
||||
```
|
||||
|
||||
Once that first PR is merged and the package is live, set the `WINGET_TOKEN`
|
||||
secret and every subsequent stable release updates the manifest automatically
|
||||
via `wingetcreate update KofTwentyTwo.gclo ... --submit`.
|
||||
Once that first PR is merged and the package is live, set the repository variable
|
||||
`WINGET_PACKAGE_ID=KofTwentyTwo.gclo` and the `WINGET_TOKEN` secret. Re-running the
|
||||
`winget` job of the bootstrapped release is not needed (it is already in winget);
|
||||
every later stable release updates the manifest automatically.
|
||||
|
||||
## Scoop and Chocolatey
|
||||
|
||||
Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256.
|
||||
Both install the same asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-256.
|
||||
|
||||
- **Scoop** needs no account: this repository is its own bucket. Users run
|
||||
`scoop bucket add gclo https://github.com/KofTwentyTwo/gclo` once, then
|
||||
`scoop install gclo`. The manifest (`bucket/gclo.json`) carries `checkver` and
|
||||
`autoupdate` entries keyed on `SHA256SUMS`, so `scoop update` works as
|
||||
soon as the manifest on `main` names the new version. The release workflow
|
||||
therefore opens its "Packaging" pull request against `main` (three generated
|
||||
files, nothing else). Note that a PR opened with the default `GITHUB_TOKEN`
|
||||
triggers no status checks, so `DEPS_PAT` should be configured (#7) for the
|
||||
required checks to run on it.
|
||||
soon as the manifest on `main` names the new version — which is why the
|
||||
`packages` job opens its pull request against `main` (three generated files,
|
||||
nothing else). Merge it.
|
||||
- **Chocolatey** needs a one-time account on chocolatey.org and its API key as
|
||||
the `CHOCO_API_KEY` secret. The workflow packs `packaging/chocolatey` on every
|
||||
stable release and pushes it when the key exists; the first version goes
|
||||
through Chocolatey's moderation queue (expect a few days), later versions are
|
||||
usually auto-approved. Users then run `choco install gclo`.
|
||||
`CHOCO_API_KEY`. The first version goes through Chocolatey's moderation queue
|
||||
(expect a few days); later versions are usually auto-approved. Users then run
|
||||
`choco install gclo`.
|
||||
- To re-point the manifests by hand (for example after a missed release):
|
||||
`./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath <zip>`.
|
||||
|
||||
@@ -109,94 +118,75 @@ Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256.
|
||||
titled `Release vX.Y.Z`, and work through it: the conformance checker
|
||||
(`tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/gclo`, attach the
|
||||
output), open scanning alerts, and for a MINOR or MAJOR the threat-model
|
||||
review (`docs/security/threat-model.md`). The release notes link the issue.
|
||||
review (`docs/security/threat-model.md`).
|
||||
|
||||
2. **Make sure `main` is green.** CI, the security scans, and CodeQL must all
|
||||
pass. The release workflow re-runs the CI gates against the tag, so a red
|
||||
`main` will fail the release anyway, just later.
|
||||
2. **Make sure `main` is green** and the `release` environment holds every
|
||||
token the table above lists for the kind of release you are cutting.
|
||||
|
||||
3. **Pick the version.** Follow semver: breaking change → major, new feature →
|
||||
minor, fix → patch. Add a prerelease suffix (`-beta.1`) if this should go to
|
||||
the `dev` channel only.
|
||||
3. **Rehearse on a prerelease.** Tag `vX.Y.Z-beta.N` first: it runs the whole
|
||||
pipeline except the stable-only publishers, on the `dev` channel. Install
|
||||
`gclo-dev-Setup.exe` on a clean machine, run a Quick Sync, and let an
|
||||
installed earlier prerelease self-update to it. Only then tag the stable
|
||||
version.
|
||||
|
||||
4. **Tag and push** (the `protect-release-tags` ruleset lets only the
|
||||
4. **Pick the version.** Follow semver: breaking change → major, new feature →
|
||||
minor, fix → patch.
|
||||
|
||||
5. **Tag and push** (the `protect-release-tags` ruleset lets only the
|
||||
repository admin create a `v*` tag):
|
||||
|
||||
```powershell
|
||||
git checkout main
|
||||
git pull
|
||||
git tag v1.2.3 # or v1.2.3-beta.1 for a dev-channel prerelease
|
||||
git tag -s v1.2.3 # or v1.2.3-beta.1 for a dev-channel prerelease
|
||||
git push origin v1.2.3
|
||||
```
|
||||
|
||||
5. **Watch the workflow.** The `Release` workflow appears under the Actions
|
||||
tab. It runs three jobs, each gated on the previous one:
|
||||
- **Verify tag** — validate the tag, derive version/channel, and check
|
||||
that the tagged commit is contained in `main`. A tag on any other commit
|
||||
fails here and nothing is built or published.
|
||||
- **Gates** — the full `ci` workflow, exactly as a pull request runs it:
|
||||
x64 build with warnings as errors, 100% line coverage on
|
||||
`gclo.Engine` / `gclo.ViewModels` / `gclo`, and the formatting gate.
|
||||
The FlaUI UI smoke suite runs as well but is **advisory** for a release,
|
||||
matching its status on `main` (not a required check yet, because UI
|
||||
automation on hosted runners is still proving itself). A red UI job
|
||||
shows in the run but does not stop publishing; look at it afterwards.
|
||||
If a *blocking* gate fails, fix the cause on `main` and cut a new patch
|
||||
version — never reuse the tag (see "If a release goes wrong").
|
||||
- **Build, package, and publish**, which will, in order:
|
||||
- publish and zip the CLI (with the LICENSE inside),
|
||||
- publish the WinUI app unpackaged and pack it with Velopack,
|
||||
- generate a CycloneDX SBOM per artifact, write `SHA256SUMS`, and attest
|
||||
provenance and SBOMs,
|
||||
- create a **draft** GitHub Release (marked prerelease for `dev`), upload
|
||||
all assets and the notes generated from the Conventional Commit titles
|
||||
since the previous release (`.github/scripts/New-ReleaseNotes.ps1`), then
|
||||
**publish** it — this repository uses immutable releases, so everything
|
||||
must land while the release is still a draft,
|
||||
- push `gclo.Engine` to nuget.org (if `NUGET_API_KEY` is set),
|
||||
- submit the winget manifest update (stable only, if `WINGET_TOKEN` is set).
|
||||
6. **Watch the workflow** under the Actions tab:
|
||||
- **Verify tag** — the tagged commit must be contained in `main`. A tag on
|
||||
any other commit fails here and nothing is built or published.
|
||||
- **Gates** — the full `ci` workflow, exactly as a pull request runs it,
|
||||
including the FlaUI end-to-end suite. If a gate fails, fix the cause on
|
||||
`main` and cut a new patch version; never reuse the tag.
|
||||
- **Release** (shared workflow): locked restore, Release build with warnings
|
||||
as errors, tests with the 100% coverage gate, publish, Velopack pack, SBOMs,
|
||||
`SHA256SUMS`, provenance, a **draft** GitHub Release with every asset and
|
||||
generated notes, then **publish** — releases are immutable, so everything
|
||||
lands while the release is still a draft.
|
||||
- **Scoop and Chocolatey**, **winget**, **NuGet** — the publishers described
|
||||
above; each fails loudly when it cannot publish.
|
||||
|
||||
6. **Verify.** Check the new release on the
|
||||
[releases page](https://github.com/KofTwentyTwo/gclo/releases): the Setup
|
||||
exe, portable zip, full package, CLI zip (and a delta package after the
|
||||
first release), `SHA256SUMS`, and the two SBOMs should all be attached;
|
||||
`gh attestation verify` passes for each asset. Tick the "Ship" section of
|
||||
the checklist issue. If the winget step ran, check your PR on
|
||||
microsoft/winget-pkgs.
|
||||
7. **Verify.** On the [releases page](https://github.com/KofTwentyTwo/gclo/releases)
|
||||
every asset listed above is attached and
|
||||
`gh attestation verify <asset> --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards`
|
||||
passes. Merge the packaging PR. Check the winget PR on microsoft/winget-pkgs.
|
||||
Tick the "Ship" section of the checklist issue.
|
||||
|
||||
7. **If something failed**, where it failed decides the recovery. The workflow
|
||||
is a single job, so "re-run failed jobs" always re-runs everything from the
|
||||
start — and because releases in this repository are **immutable**, a
|
||||
published release's tag and assets are frozen and cannot be re-uploaded.
|
||||
8. **If something failed**, where it failed decides the recovery:
|
||||
|
||||
- **Failed before "Publish the completed release"** (build, tests,
|
||||
packaging, asset upload, changelog): nothing is public yet. Delete the
|
||||
draft release if one was created — drafts can always be deleted:
|
||||
- **In `verify`, `gates`, or inside `release` before "Publish release"**:
|
||||
nothing is public. Delete the draft release if one was created
|
||||
(`gh release delete v1.2.3 --yes`), fix the cause through a pull request,
|
||||
and re-run the workflow from the Actions UI; the tag is unchanged and
|
||||
everything is rebuilt from it. A fix that changes the shipped code needs a
|
||||
new patch version instead.
|
||||
- **In a publisher after the release was published**: the release is fine and
|
||||
frozen. Add the missing token or variable and **re-run the failed job
|
||||
only**; do not re-run the whole workflow, which would try to recreate the
|
||||
release. The manual equivalents, should a job keep failing:
|
||||
|
||||
```powershell
|
||||
gh release delete v1.2.3 --yes
|
||||
```
|
||||
|
||||
Then fix the cause and re-run the job from the Actions UI; the tag is
|
||||
unchanged and everything is rebuilt from it.
|
||||
|
||||
- **Failed after the release was published** (the NuGet push or the winget
|
||||
submission): the release itself is fine but frozen — do **not** re-run
|
||||
the job, which would try to recreate it. Run the missing step manually
|
||||
instead:
|
||||
|
||||
```powershell
|
||||
# NuGet push (needs an API key with push rights for gclo.Engine)
|
||||
dotnet pack gclo.Engine -c Release -p:Version=1.2.3 -o artifacts/nuget
|
||||
dotnet nuget push "artifacts/nuget/*.nupkg" --api-key <NUGET_API_KEY> --source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
# Scoop + Chocolatey manifests, then push the package and open the PR by hand
|
||||
./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath .\gclo-cli-win-x64-1.2.3.zip
|
||||
choco pack packaging/chocolatey/gclo.nuspec --outputdirectory artifacts
|
||||
choco push artifacts\gclo.1.2.3.nupkg --source https://push.chocolatey.org/ --api-key <CHOCO_API_KEY>
|
||||
|
||||
# winget manifest update (stable releases only)
|
||||
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
.\wingetcreate.exe update KofTwentyTwo.gclo --urls https://github.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --version 1.2.3 --submit --token <WINGET_TOKEN>
|
||||
```
|
||||
|
||||
If the published release itself is broken, do not try to fix it in
|
||||
place and do not delete it: see "A broken release" below.
|
||||
- **The published release itself is broken**: do not fix it in place and do
|
||||
not delete it; see "A broken release".
|
||||
|
||||
### A broken release
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Users asked for package-manager installs of the CLI (#9). Scoop needs a bucket (a
|
||||
git repository with `bucket/<app>.json`) read from the default branch; Chocolatey
|
||||
needs a chocolatey.org account, an API key, and moderation of the first push. Both
|
||||
install the same release asset, `gclo-cli-win-x64.zip`, by URL and SHA-256, so their
|
||||
install the same release asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-256, so their
|
||||
manifests must be updated on every stable release without hand edits.
|
||||
|
||||
## Options considered
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR 0007: Release through the standards' shared Velopack workflow
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-09
|
||||
- **Issue:** [#76](https://github.com/KofTwentyTwo/gclo/issues/76)
|
||||
|
||||
## Context
|
||||
|
||||
Until 1.0.1 the release pipeline built, packed, attested and published inside this
|
||||
repository's `release.yml`. The KofTwentyTwo standards require the build to run in a
|
||||
shared reusable workflow (K22-CI-30) so the provenance attestation names a workflow
|
||||
the calling repository cannot edit (SLSA Build L3); gclo carried exception EX-0004
|
||||
until that workflow existed. It now does: `release-velopack.yml` in
|
||||
KofTwentyTwo/standards builds, tests, gates coverage, publishes, packs with Velopack,
|
||||
generates SBOMs with Syft, writes `SHA256SUMS`, attests, and publishes the release.
|
||||
|
||||
Publishing to Scoop, Chocolatey, winget and (optionally) NuGet is specific to gclo,
|
||||
and the previous workflow skipped any of them whose secret was missing, so a green
|
||||
run did not mean every channel had been published.
|
||||
|
||||
## Decision
|
||||
|
||||
- `release.yml` keeps only what is gclo's: the "tag is on main" check, the `ci`
|
||||
gates (K22-CI-31), the call to the shared workflow pinned to a commit, and the
|
||||
caller-side publishing jobs.
|
||||
- Publishing jobs run in the `release` environment, which only `v*` tags can use,
|
||||
and **fail** when a token or variable they need is absent. NuGet publishing is an
|
||||
explicit opt-in (`NUGET_PUBLISH` variable) because gclo.Engine has no consumers
|
||||
yet; once opted in, a missing key fails too.
|
||||
- Asset names follow the shared workflow: the CLI zip is
|
||||
`gclo-cli-win-x64-<version>.zip`, SBOMs are `gclo-<version>.cdx.json` and
|
||||
`gclo-cli-<version>.cdx.json`, and release notes are GitHub's generated notes.
|
||||
The Scoop and Chocolatey manifests, their updater script, and the tests follow.
|
||||
- The LICENSE ships inside the published app and CLI through the project files,
|
||||
since the shared workflow does not add it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The provenance's signer is `KofTwentyTwo/standards`; verification commands pass
|
||||
`--signer-repo KofTwentyTwo/standards` (README, "Verifying a release").
|
||||
- Exception EX-0004 closes after the first successful release on the shared
|
||||
workflow; the first winget submission is still manual (`wingetcreate new`).
|
||||
- Jobs are separate, so a failed publisher can be re-run alone after its token is
|
||||
added, without rebuilding or touching the immutable release.
|
||||
- Changes to the build itself happen in the standards repository and reach gclo by
|
||||
moving the pin.
|
||||
@@ -93,7 +93,7 @@ flowchart LR
|
||||
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
|
||||
| T9 | WSL recovery | Elevation of privilege | Hostile org/repo names injected into the shell script | Low | High | Script fed on stdin with positional parameters; names validated as repository names first; no string interpolation into the script | Mitigated |
|
||||
| T10 | Logs and crash output | Information disclosure | Exception text carries a token or private path | Low | Medium | Crash net logs messages only; log review in incident handling; tokens never in exception messages (tests) | Mitigated |
|
||||
| T11 | Release pipeline | Tampering | Compromised action or cache poisons a release | Low | Critical | SHA-pinned actions, no caches in the release job, read-only tokens, OIDC attestations, `protect-release-tags`, required gates re-run on the tag | Mitigated |
|
||||
| T11 | Release pipeline | Tampering | Compromised action or cache poisons a release | Low | Critical | Build runs in the standards' shared release workflow (SLSA Build L3 provenance, `--signer-repo KofTwentyTwo/standards`), SHA-pinned actions, no caches, read-only tokens, publishing tokens only in the tag-restricted `release` environment, `protect-release-tags`, required gates re-run on the tag | Mitigated |
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
|
||||
|
||||
@@ -110,3 +110,4 @@ flowchart LR
|
||||
| Date | Version | Reviewer | Changes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | Initial model, covering the path-recovery, WSL clone, vault scoping, package-manager, and SBOM changes of 1.0.1 |
|
||||
| 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | T11 updated: releases build in the shared standards workflow; publishing tokens moved to the `release` environment (#76) |
|
||||
@@ -38,6 +38,18 @@ public sealed class PackagingManifestTests
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The release asset the manifests install: releases up to 1.0.0 shipped it as
|
||||
/// gclo-cli-win-x64.zip, the shared release workflow names it with the version.
|
||||
/// </summary>
|
||||
private static string CliZipUrlPattern(string version)
|
||||
{
|
||||
string v = Regex.Escape(version);
|
||||
return $@"^https://github\.com/KofTwentyTwo/gclo/releases/download/v{v}/gclo-cli-win-x64(-{v})?\.zip$";
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static (string Version, string Url, string Hash) ReadScoop()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot, "bucket", "gclo.json")));
|
||||
@@ -47,7 +59,7 @@ public sealed class PackagingManifestTests
|
||||
Assert.Equal("MIT", root.GetProperty("license").GetString());
|
||||
Assert.Equal("github", root.GetProperty("checkver").GetString());
|
||||
string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!;
|
||||
Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl);
|
||||
Assert.Contains("v$version/gclo-cli-win-x64-$version.zip", autoupdateUrl);
|
||||
Assert.EndsWith("/SHA256SUMS", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString());
|
||||
return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!);
|
||||
}
|
||||
@@ -78,7 +90,7 @@ public sealed class PackagingManifestTests
|
||||
(string? version, string? url, string? hash) = ReadScoop();
|
||||
|
||||
Assert.Matches(@"^\d+\.\d+\.\d+$", version); // package managers get stable releases only
|
||||
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
|
||||
Assert.Matches(CliZipUrlPattern(version), url);
|
||||
Assert.Matches(s_sha256, hash);
|
||||
}
|
||||
|
||||
@@ -90,7 +102,7 @@ public sealed class PackagingManifestTests
|
||||
(string? version, string? url, string? hash) = ReadChocolatey();
|
||||
|
||||
Assert.Matches(@"^\d+\.\d+\.\d+$", version);
|
||||
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
|
||||
Assert.Matches(CliZipUrlPattern(version), url);
|
||||
Assert.Matches(s_sha256, hash);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<!-- The release publishes win-x64; declaring it keeps that runtime graph in
|
||||
packages.lock.json so the publish can restore in locked mode. -->
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<!-- Always on, so the lock file matches the single-file release publish (see gclo.Engine.csproj). -->
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>gclo</AssemblyName>
|
||||
@@ -24,4 +26,9 @@
|
||||
<InternalsVisibleTo Include="gclo.Cli.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Released artifacts carry the license (K22-REPO-01); kept beside the single-file exe. -->
|
||||
<Content Include="..\LICENSE" Link="LICENSE" CopyToPublishDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -8,6 +8,12 @@
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
},
|
||||
"LibGit2Sharp.NativeBinaries": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.324",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Keeps the restore graph identical between a plain build and the release publish
|
||||
(runtime win-x64, self-contained, single file), so the lock files pass
|
||||
locked mode in both: the RID graph is always present, and the single-file
|
||||
analyzer (which pulls Microsoft.NET.ILLink.Tasks) is always on.
|
||||
-->
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
},
|
||||
"Octokit": {
|
||||
"type": "Direct",
|
||||
"requested": "[14.0.0, )",
|
||||
@@ -28,6 +34,13 @@
|
||||
"resolved": "2.0.324",
|
||||
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"LibGit2Sharp.NativeBinaries": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.324",
|
||||
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Keeps the restore graph identical between a plain build and the release publish
|
||||
(runtime win-x64, self-contained, single file), so the lock files pass
|
||||
locked mode in both: the RID graph is always present, and the single-file
|
||||
analyzer (which pulls Microsoft.NET.ILLink.Tasks) is always on.
|
||||
-->
|
||||
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "3.0.296",
|
||||
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
},
|
||||
"LibGit2Sharp.NativeBinaries": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.324",
|
||||
@@ -41,6 +47,13 @@
|
||||
"resolved": "14.0.0",
|
||||
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"LibGit2Sharp.NativeBinaries": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.324",
|
||||
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@
|
||||
build has no MSIX packaging step to carry them otherwise. -->
|
||||
<Content Include="Assets\Brand\gclo-icon.svg" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Assets\Brand\gclo.ico" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- Released artifacts carry the license (K22-REPO-01). -->
|
||||
<Content Include="..\LICENSE" Link="LICENSE" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in new issue
Block a user