ci: release through the standards' shared Velopack workflow; publishers fail instead of skip #77

Merged
KofTwentyTwo merged 2 commits from ci/shared-velopack-release into main 2026-10-09 23:03:21 +00:00
20 changed files with 415 additions and 719 deletions

No files matched your search

-289
View File
@@ -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"
+3 -3
View File
@@ -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. ##
+3 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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) |
+15 -3
View File
@@ -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);
}
+7
View File
@@ -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>
+6
View File
@@ -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",
+8
View File
@@ -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>
+13
View File
@@ -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=="
}
}
}
}
+8
View File
@@ -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>
+13
View File
@@ -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=="
}
}
}
}
+2
View File
@@ -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>