feat(ci): reusable Velopack release workflow for Windows desktop apps #5

Merged
KofTwentyTwo merged 1 commits from feat/velopack-release into main 2026-10-09 21:44:22 +00:00
6 changed files with 765 additions and 13 deletions

No files matched your search

+612
View File
@@ -0,0 +1,612 @@
# Release of a Windows desktop application with Velopack (K22-CI-30, K22-CI-31,
# K22-REL-01..07; architecture/windows-desktop-app.md section 6). Called from the
# calling repository's tag workflow; see templates/workflows/release-velopack.yml:
#
# on:
# push:
# tags: ['v*']
# jobs:
# release:
# uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@<sha> # vX.Y.Z
# permissions:
# contents: write # create the GitHub release
# id-token: write # sign attestations (Sigstore) and, when signing, Azure OIDC
# attestations: write # store attestations
# with:
# solution: MyApp.slnx
# app-project: src/MyApp/MyApp.csproj
# app-id: myapp
# main-exe: MyApp.exe
# icon: src/MyApp/Assets/Brand/app.ico
#
# What this workflow does, in order:
# build (Windows, read-only): validate the tag and derive the Velopack channel
# (prerelease -> dev, otherwise stable), locked restore, Release build with
# warnings as errors, tests and coverage gate (K22-CI-31), then publish the
# app unpackaged and self-contained, and the optional CLI as a single file.
# package (Windows, environment `release`): optionally Authenticode-sign the
# published binaries with Azure Trusted Signing over OIDC (input `sign`,
# off until exception EX-0002 closes), download the previous release on the
# same channel as the delta baseline, `vpk pack` (which signs Setup.exe with
# the same identity when `sign` is on), and zip the CLI. Only this build's
# Velopack assets (from assets.<channel>.json) are kept, with a
# releases.<channel>.json feed limited to them, exactly as `vpk upload` does.
# sbom (Linux): CycloneDX 1.6 SBOMs of the published app and CLI (Syft), attested
# against the installer, portable zip, full package, and CLI zip.
# attest (Linux): SHA256SUMS over every asset (including the fixed-name channel
# files), then SLSA build provenance for all of them. The build runs here, in
# KofTwentyTwo/standards, so the provenance names this workflow as the
# builder (SLSA Build L3).
# release (Linux): draft GitHub release with generated notes and every asset, then
# publish (releases are immutable once published).
#
# winget is not done here: its token belongs to the calling repository's `release`
# environment, so the optional winget job lives in the caller (see the template).
name: release-velopack
on:
workflow_call:
inputs:
solution:
description: Solution (.slnx or .sln) to build and test.
type: string
required: true
app-project:
description: Path of the WinUI or WPF application project to publish.
type: string
required: true
app-id:
description: Velopack package id (lowercase), also the asset name prefix.
type: string
required: true
main-exe:
description: File name of the application executable, e.g. MyApp.exe.
type: string
required: true
icon:
description: Path of the .ico used for Setup.exe.
type: string
required: true
platform:
description: Solution platform passed as -p:Platform.
type: string
default: x64
runtime:
description: Runtime identifier to publish for.
type: string
default: win-x64
coverage-packages:
description: Comma-separated coverage packages that must meet coverage-minimum. Empty disables the gate.
type: string
default: ""
coverage-minimum:
description: Minimum line coverage, in percent.
type: number
default: 100
test-projects:
description: Newline-separated test projects to run with coverage before publishing.
type: string
default: ""
cli-project:
description: Optional command-line project, published as a self-contained single-file exe in <app-id>-cli-<runtime>-<version>.zip.
type: string
default: ""
repository-name:
description: Display name used in the release title. Defaults to the repository name.
type: string
default: ""
sign:
description: >-
Authenticode-sign binaries and Setup.exe with Azure Trusted Signing (OIDC). Requires the
variables AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT,
TRUSTED_SIGNING_ACCOUNT, and TRUSTED_SIGNING_PROFILE in the caller's `release` environment.
Off until exception EX-0002 (signing identity validation) closes.
type: boolean
default: false
outputs:
version:
description: The released version (tag without the leading v).
value: ${{ jobs.build.outputs.version }}
prerelease:
description: '"true" when the version has a prerelease suffix.'
value: ${{ jobs.build.outputs.prerelease }}
channel:
description: The Velopack channel (stable or dev).
value: ${{ jobs.build.outputs.channel }}
setup:
description: File name of the Setup executable in the release.
value: ${{ jobs.package.outputs.setup }}
permissions: {}
jobs:
build:
name: build
runs-on: windows-latest
timeout-minutes: 60
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
prerelease: ${{ steps.version.outputs.prerelease }}
channel: ${{ steps.version.outputs.channel }}
env:
# Prefixed: MSBuild turns every environment variable into a property, so names like
# PLATFORM or VERSION would silently change the build.
K22_SOLUTION: ${{ inputs.solution }}
K22_PLATFORM: ${{ inputs.platform }}
K22_RUNTIME: ${{ inputs.runtime }}
K22_APP_PROJECT: ${{ inputs.app-project }}
K22_CLI_PROJECT: ${{ inputs.cli-project }}
TEST_PROJECTS: ${{ inputs.test-projects }}
COVERAGE_PACKAGES: ${{ inputs.coverage-packages }}
COVERAGE_MINIMUM: ${{ inputs.coverage-minimum }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Strict SemVer 2.0 tag; build metadata ('+...') is rejected (K22-REL-01). A
# prerelease goes to the Velopack `dev` channel, everything else to `stable`; an
# installed app only ever updates within its own channel.
- name: Validate tag and derive version and channel
id: version
shell: pwsh
env:
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
if ($env:REF_TYPE -ne 'tag')
{
Write-Output "::error::Releases run only for tags; got $env:REF_TYPE '$env:REF_NAME'."
exit 1
}
$semver = '^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 ($env:REF_NAME -cnotmatch $semver)
{
Write-Output "::error::Tag '$env:REF_NAME' is not vMAJOR.MINOR.PATCH with an optional prerelease suffix."
exit 1
}
$version = $env:REF_NAME.Substring(1)
$isPrerelease = $version.Contains('-')
$prerelease = $isPrerelease.ToString().ToLowerInvariant()
$channel = if ($isPrerelease) { 'dev' } else { 'stable' }
Write-Output "Version $version, prerelease $prerelease, channel $channel"
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
# No caches anywhere in this workflow (K22-CI-15): a cache written by a pull
# request run must never reach a release.
- name: Restore (locked)
shell: pwsh
run: dotnet restore $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --locked-mode
- name: Build (Release, warnings as errors)
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: dotnet build $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" "-p:Version=$env:K22_VERSION" -warnaserror --no-restore
- name: Test with coverage
if: inputs.test-projects != ''
shell: pwsh
run: |
$settings = @()
if (Test-Path -LiteralPath coverage.runsettings) { $settings = @('--settings', 'coverage.runsettings') }
$projects = $env:TEST_PROJECTS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
foreach ($project in $projects)
{
dotnet test $project -c Release --no-build @settings --collect:"XPlat Code Coverage" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Coverage gate
if: inputs.coverage-packages != ''
shell: pwsh
run: |
$files = @(Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
if ($files.Count -eq 0)
{
Write-Output '::error::No coverage.cobertura.xml under TestResults.'
exit 1
}
$reports = foreach ($file in $files) { [xml] (Get-Content -LiteralPath $file.FullName -Raw) }
$required = [double] $env:COVERAGE_MINIMUM / 100
$failed = $false
foreach ($name in ($env:COVERAGE_PACKAGES -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }))
{
$rates = foreach ($report in $reports)
{
$package = @($report.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates)
{
Write-Output "::error::Package '$name' was not found in any coverage report."
$failed = $true
continue
}
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output ('{0} line coverage: {1}% (required {2}%)' -f $name, [math]::Round($best * 100, 2), $env:COVERAGE_MINIMUM)
if ($best -lt $required) { $failed = $true }
}
if ($failed) { exit 1 }
# Unpackaged (no MSIX) and self-contained, including the Windows App SDK runtime:
# the shape Velopack installs per user without admin rights. Trimming stays off
# because WinUI and WPF rely on reflection the trimmer cannot see.
- name: Publish app (unpackaged, self-contained)
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: >-
dotnet publish $env:K22_APP_PROJECT -c Release "-p:Platform=$env:K22_PLATFORM" -r $env:K22_RUNTIME --self-contained
"-p:Version=$env:K22_VERSION" -p:RestoreLockedMode=true -p:WindowsPackageType=None
-p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app
- name: Publish CLI (self-contained single file)
if: inputs.cli-project != ''
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: >-
dotnet publish $env:K22_CLI_PROJECT -c Release -r $env:K22_RUNTIME --self-contained
"-p:Version=$env:K22_VERSION" -p:RestoreLockedMode=true -p:PublishSingleFile=true -o publish/cli
- name: Upload published app
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: publish-app
path: publish/app
if-no-files-found: error
- name: Upload published CLI
if: inputs.cli-project != ''
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: publish-cli
path: publish/cli
if-no-files-found: error
package:
name: package
needs: build
runs-on: windows-latest
timeout-minutes: 45
# The signing identity (Azure variables) lives in the caller's `release` environment,
# which only allows protected v* tags (K22-CI-16).
environment: release
permissions:
contents: read # check out the icon; read earlier releases for the delta baseline
id-token: write # Azure OIDC login, used only when inputs.sign is true
outputs:
setup: ${{ steps.collect.outputs.setup }}
env:
K22_VERSION: ${{ needs.build.outputs.version }}
K22_CHANNEL: ${{ needs.build.outputs.channel }}
K22_PRERELEASE: ${{ needs.build.outputs.prerelease }}
K22_APP_ID: ${{ inputs.app-id }}
K22_MAIN_EXE: ${{ inputs.main-exe }}
K22_ICON: ${{ inputs.icon }}
K22_RUNTIME: ${{ inputs.runtime }}
K22_SIGN: ${{ inputs.sign }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
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
- name: Download published app
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: publish-app
path: publish/app
- name: Download published CLI
if: inputs.cli-project != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: publish-cli
path: publish/cli
# Signing is OFF by default: exception EX-0002 records that the Azure Trusted
# Signing identity is still being validated. Until then, integrity and origin rest
# on SHA256SUMS and the build-provenance attestations (K22-REL-04).
- name: Azure login (OIDC)
if: inputs.sign
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# Signs the app's own executables and DLLs (and the CLI) before packing, so the
# binaries inside the installer, the portable zip, and the update packages are
# all signed (K22-REL-05).
- name: Sign published binaries (Azure Trusted Signing)
if: inputs.sign
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
endpoint: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ vars.TRUSTED_SIGNING_PROFILE }}
files-folder: ${{ github.workspace }}\publish
files-folder-filter: exe,dll
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
cache-dependencies: false
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
# vpk signs what it adds (Setup.exe and the update stub) with the same identity,
# reading this metadata file and the Azure CLI login above.
- name: Write signing metadata for vpk
if: inputs.sign
shell: pwsh
env:
TRUSTED_SIGNING_ENDPOINT: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
TRUSTED_SIGNING_ACCOUNT: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
TRUSTED_SIGNING_PROFILE: ${{ vars.TRUSTED_SIGNING_PROFILE }}
run: |
@{
Endpoint = $env:TRUSTED_SIGNING_ENDPOINT
CodeSigningAccountName = $env:TRUSTED_SIGNING_ACCOUNT
CertificateProfileName = $env:TRUSTED_SIGNING_PROFILE
} | ConvertTo-Json | Set-Content -LiteralPath "$env:RUNNER_TEMP\trusted-signing.json" -Encoding utf8
# The vpk version matches the Velopack NuGet package the apps reference (AppKit).
- name: Install vpk
shell: pwsh
run: dotnet tool install --global vpk --version 1.2.161
# The first release on a channel has no predecessor; vpk then simply produces no
# delta package, so a failed download must not fail the release.
- name: Download previous release (delta baseline)
continue-on-error: true
shell: pwsh
env:
GITHUB_TOKEN: ${{ github.token }}
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
run: |
$pre = @()
if ($env:K22_PRERELEASE -eq 'true') { $pre = @('--pre') }
vpk download github --repoUrl $env:REPOSITORY_URL --token $env:GITHUB_TOKEN --channel $env:K22_CHANNEL --outputDir Releases @pre
- name: Velopack pack
shell: pwsh
run: |
$sign = @()
if ($env:K22_SIGN -eq 'true') { $sign = @('--azureTrustedSignFile', "$env:RUNNER_TEMP\trusted-signing.json") }
vpk pack --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases @sign
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Keeps only what this build produced, as `vpk upload` does: the files listed in
# assets.<channel>.json, plus a releases.<channel>.json feed trimmed to this build's
# packages (the local feed may also list the downloaded baseline).
- name: Collect release assets
id: collect
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path release | Out-Null
$assets = @(Get-Content -LiteralPath "Releases/assets.$env:K22_CHANNEL.json" -Raw | ConvertFrom-Json)
if ($assets.Count -eq 0)
{
Write-Output "::error::vpk produced no assets for channel $env:K22_CHANNEL."
exit 1
}
$names = foreach ($asset in $assets) { [IO.Path]::GetFileName($asset.RelativeFileName) }
foreach ($asset in $assets) { Copy-Item -LiteralPath (Join-Path 'Releases' $asset.RelativeFileName) -Destination release }
$feedName = "releases.$env:K22_CHANNEL.json"
$feed = Get-Content -LiteralPath (Join-Path 'Releases' $feedName) -Raw | ConvertFrom-Json
$feed.Assets = @($feed.Assets | Where-Object { $_.FileName -in $names })
$feed | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path 'release' $feedName) -Encoding utf8NoBOM
$setup = $names | Where-Object { $_ -like '*-Setup.exe' } | Select-Object -First 1
Write-Output "Assets: $($names -join ', '); feed: $feedName; setup: $setup"
"setup=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Zip CLI
if: inputs.cli-project != ''
shell: pwsh
run: Compress-Archive -Path publish/cli/* -DestinationPath "release/$env:K22_APP_ID-cli-$env:K22_RUNTIME-$env:K22_VERSION.zip"
- name: Upload release files
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-files
path: release/*
if-no-files-found: error
sbom:
name: sbom
needs: [build, package]
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
id-token: write # sign the SBOM attestations
attestations: write # store them
env:
K22_VERSION: ${{ needs.build.outputs.version }}
K22_APP_ID: ${{ inputs.app-id }}
steps:
- name: Download published app
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: publish-app
path: publish/app
- name: Download published CLI
if: inputs.cli-project != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: publish-cli
path: publish/cli
- name: Download release files
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-files
path: release
- name: Prepare SBOM folder
run: mkdir -p sbom
# CycloneDX 1.6 from the published output: Syft reads the .deps.json files and the
# binaries, i.e. exactly what ships (K22-REL-06).
- name: Generate app SBOM (Syft)
uses: docker://ghcr.io/anchore/syft:v1.54.1@sha256:3eb5379ba7b409c3f4069b686110527af0c47df993fa5c10d13e7cf34f49b1aa
with:
args: >-
scan dir:/github/workspace/publish/app
--source-name ${{ inputs.app-id }}
--source-version ${{ needs.build.outputs.version }}
--output cyclonedx-json@1.6=/github/workspace/sbom/${{ inputs.app-id }}-${{ needs.build.outputs.version }}.cdx.json
--quiet
- name: Generate CLI SBOM (Syft)
if: inputs.cli-project != ''
uses: docker://ghcr.io/anchore/syft:v1.54.1@sha256:3eb5379ba7b409c3f4069b686110527af0c47df993fa5c10d13e7cf34f49b1aa
with:
args: >-
scan dir:/github/workspace/publish/cli
--source-name ${{ inputs.app-id }}-cli
--source-version ${{ needs.build.outputs.version }}
--output cyclonedx-json@1.6=/github/workspace/sbom/${{ inputs.app-id }}-cli-${{ needs.build.outputs.version }}.cdx.json
--quiet
# The app SBOM describes every artifact that carries the app: installer, portable
# zip, and the full update package.
- name: Attest app SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: |
release/*-Setup.exe
release/*-Portable.zip
release/*-full.nupkg
sbom-path: sbom/${{ inputs.app-id }}-${{ needs.build.outputs.version }}.cdx.json
- name: Attest CLI SBOM
if: inputs.cli-project != ''
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: release/*-cli-*.zip
sbom-path: sbom/${{ inputs.app-id }}-cli-${{ needs.build.outputs.version }}.cdx.json
- name: Upload SBOMs
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: sbom
path: sbom/*.cdx.json
if-no-files-found: error
attest:
name: attest
needs: [build, sbom]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
id-token: write # sign the provenance attestation
attestations: write # store it
steps:
- name: Download release files
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-files
path: release
- name: Download SBOMs
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: sbom
path: release
# Lists every asset, including Velopack's fixed-name files (Setup, portable zip,
# releases.<channel>.json), so assets without the version in their name are still
# tied to this release (K22-REL-03, K22-REL-04).
- name: Checksums
working-directory: release
run: |
find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort | xargs -d '\n' sha256sum > "$RUNNER_TEMP/SHA256SUMS"
mv "$RUNNER_TEMP/SHA256SUMS" SHA256SUMS
cat SHA256SUMS
# Anyone can verify an asset with:
# gh attestation verify <file> --repo <owner/repo> --signer-repo KofTwentyTwo/standards
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: release/*
- name: Upload release assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: release/*
if-no-files-found: error
release:
name: release
needs: [build, attest]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write # create and publish the GitHub release
steps:
- name: Download release assets
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-assets
path: release
# Releases are immutable once published (K22-REL-07): everything lands on a draft
# first, and publishing is the last step. Velopack's GitHub source reads
# releases.<channel>.json from published releases, so installed apps see the
# update only after this publish.
- name: Create draft release with assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
K22_VERSION: ${{ needs.build.outputs.version }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
DISPLAY_NAME: ${{ inputs.repository-name || github.event.repository.name }}
run: |
prerelease_flag=()
if [[ "$PRERELEASE" == "true" ]]; then prerelease_flag=(--prerelease); fi
gh release create "$TAG" --draft --verify-tag --generate-notes \
--title "$DISPLAY_NAME $K22_VERSION" "${prerelease_flag[@]}" release/*
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: gh release edit "$TAG" --draft=false
+1
View File
@@ -236,6 +236,7 @@ from each repository:
| `codeql.yml` | `codeql / analyze (<language>)` | CodeQL `security-extended`, failing on high severity and above |
| `dotnet.yml` | `ci / build-test`, `ci / format`, `ci / ui-tests` | .NET locked restore, zero-warning build, tests, coverage gate, Kingsrook format check, FlaUI UI tests |
| `release-nuget.yml` | (tag workflow) | NuGet release: gates, pack, SBOM, checksums, SLSA provenance, GitHub release; pairs with a caller `publish` job for nuget.org |
| `release-velopack.yml` | (tag workflow) | Windows app release: gates, self-contained publish, optional Azure Trusted Signing, Velopack pack (stable/dev channels, deltas), optional CLI zip, SBOMs, checksums, SLSA provenance, GitHub release; optional caller `winget` job |
A caller looks like this:
+35 -12
View File
@@ -106,21 +106,44 @@ message or a log entry.
```mermaid
flowchart LR
tag["tag v1.2.3"] --> gates["re-run gates<br/>build · tests · coverage · scans"]
gates --> publish["publish self-contained<br/>app + CLI"]
publish --> sign["Authenticode sign<br/>(Azure Trusted Signing, OIDC)"]
sign --> pack["vpk pack<br/>Setup · portable · full/delta"]
pack --> sbom["SBOM (CycloneDX)<br/>SHA256SUMS"]
sbom --> attest["attest provenance + SBOM"]
attest --> draft["draft release<br/>upload all assets + notes"]
tag["tag v1.2.3<br/>(v1.2.3-beta.1 → dev)"] --> gates["build job<br/>locked restore · build · tests · coverage"]
gates --> publish["publish self-contained<br/>app + optional CLI"]
publish --> sign["package job<br/>Authenticode sign (Azure Trusted Signing, OIDC)<br/>when sign: true (EX-0002)"]
sign --> pack["vpk pack<br/>Setup · portable · full/delta · feed"]
pack --> sbom["sbom job<br/>CycloneDX of the published output"]
sbom --> attest["attest job<br/>SHA256SUMS · provenance"]
attest --> draft["release job<br/>draft release, all assets + notes"]
draft --> pub["publish (immutable)"]
pub --> winget["winget update (stable)"]
pub --> winget["caller winget job<br/>(stable, optional)"]
```
The pipeline is the shared reusable release workflow in `KofTwentyTwo/standards`
(SLSA Build L3, [`K22-CI-30`](../standards/ci-cd.md#release-pipelines)). Signing happens
**before** `vpk pack` so the binaries inside the installer and the update packages are
signed, and Velopack signs its own `Setup.exe` with the same identity.
The pipeline is the shared reusable workflow
[`release-velopack.yml`](../.github/workflows/release-velopack.yml) in
`KofTwentyTwo/standards`, called from the app's tag workflow
([template](../templates/workflows/release-velopack.yml)); because the build runs in the
reusable workflow, the provenance names it as the builder (SLSA Build L3,
[`K22-CI-30`](../standards/ci-cd.md#release-pipelines)).
1. **build** (Windows, read-only token): validates the tag (strict SemVer) and derives
the channel (`dev` for a prerelease, `stable` otherwise), re-runs the gates (locked
restore, zero-warning Release build, tests, coverage gate), then publishes the app
unpackaged and self-contained (Windows App SDK included, no trimming) and the optional
CLI as a self-contained single file.
2. **package** (Windows, `release` environment): when `sign` is on, logs in to Azure with
OIDC and Authenticode-signs every published `.exe` and `.dll` **before** packing, so the
binaries inside the installer, the portable zip, and the update packages are signed;
`vpk pack` then signs `Setup.exe` with the same identity. Signing is off until
exception [EX-0002](../exceptions/register.md#ex-0002) closes. It downloads the previous
release on the same channel as the delta baseline, runs `vpk pack`, and keeps only this
build's assets with an update feed limited to them.
3. **sbom** (Linux): CycloneDX 1.6 SBOMs of the published app and CLI, attested against
the installer, portable zip, full package, and CLI zip.
4. **attest** (Linux): `SHA256SUMS` over every asset (including the fixed-name Setup,
portable, and feed files), then build provenance for all of them.
5. **release** (Linux): a draft release with every asset and generated notes, then
publish. Installed apps see the update only once the release is published.
6. **winget** (in the app's own workflow, stable releases only, optional): submits the
manifest update with the app's `WINGET_TOKEN`.
## 7. Threat model summary
+2 -1
View File
@@ -130,7 +130,8 @@ badge.
**K22-CI-30 (MUST)** Releases are built only by a workflow triggered by a protected
`v*` tag, running on a GitHub-hosted runner, through a **reusable workflow** from
`KofTwentyTwo/standards` (`release-nuget.yml` for NuGet packages), so the build
`KofTwentyTwo/standards` (`release-nuget.yml` for NuGet packages, `release-velopack.yml`
for Windows desktop applications), so the build
platform and its definition are isolated from the calling repository. Publishing to a
registry whose trusted publishing binds the token to the calling repository's own
workflow file (nuget.org does) runs in a job of the caller's tag workflow, after it has
+26
View File
@@ -104,6 +104,32 @@ action, made once per repository on nuget.org (profile → Trusted Publishing):
Then set the repository variable `NUGET_USER` to the nuget.org profile name. Until the
variable is set, the `publish` job is skipped and the release stays GitHub-only.
### Windows desktop applications
Windows apps are released by the shared
[`release-velopack.yml`](../.github/workflows/release-velopack.yml) workflow
([template](../templates/workflows/release-velopack.yml)), as described in the
[Windows reference architecture](../architecture/windows-desktop-app.md#6-build-and-release-pipeline).
It re-runs the gates, publishes the app unpackaged and self-contained (and an optional
single-file CLI), packs it with Velopack on the `stable` channel (or `dev` for a
prerelease tag), writes CycloneDX SBOMs of the published output, `SHA256SUMS`, and
provenance and SBOM attestations, and publishes the GitHub release that installed apps
update from.
| Asset | Name |
| --- | --- |
| Installer | `<app-id>-<channel>-Setup.exe` (fixed name per channel, listed in `SHA256SUMS`) |
| Portable build | `<app-id>-<channel>-Portable.zip` (fixed name, listed in `SHA256SUMS`) |
| Update packages | `<app-id>-<version>-<channel>-full.nupkg`, and `-delta.nupkg` after the first release on a channel |
| Update feed | `releases.<channel>.json` (this build's packages only) |
| CLI | `<app-id>-cli-<runtime>-<version>.zip` |
| SBOMs | `<app-id>-<version>.cdx.json`, `<app-id>-cli-<version>.cdx.json` |
Authenticode signing with Azure Trusted Signing (OIDC, `sign: true`) is built in and off
until exception [EX-0002](../exceptions/register.md#ex-0002) closes. The optional winget
update runs in the calling repository's workflow, because its token belongs to that
repository's `release` environment.
## Release process
**K22-REL-12 (MUST)** Each MINOR or MAJOR release has a release checklist (an issue made
+89
View File
@@ -0,0 +1,89 @@
# Windows desktop application release for a KofTwentyTwo repository (K22-CI-30,
# K22-REL-*, architecture/windows-desktop-app.md). Copy to .github/workflows/release.yml.
# Pushing a protected v* tag builds, tests, publishes, packs with Velopack, generates
# SBOMs, attests, and publishes the GitHub release through the shared reusable workflow.
#
# Tags: v1.2.3 -> Velopack channel `stable`; v1.2.3-beta.1 -> channel `dev` (prerelease).
#
# One-time owner setup:
# - Environment `release` (Settings -> Environments) with a deployment rule that allows
# only tags matching v*.
# - Code signing (when exception EX-0002 closes): add the environment variables
# AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT,
# TRUSTED_SIGNING_ACCOUNT, TRUSTED_SIGNING_PROFILE (a federated credential for this
# repository's `release` environment on the Azure app registration), then set
# `sign: true` below.
# - winget (optional, stable releases only): the package must already exist in
# microsoft/winget-pkgs. Submit it once by hand with
# wingetcreate new <Setup.exe URL of the first stable release>
# then add a WINGET_TOKEN secret (a fine-grained token that can fork and open pull
# requests on public repositories, expiring within 90 days, K22-SEC-03) to the
# `release` environment, and set the repository variable WINGET_PACKAGE_ID.
name: release
on:
push:
tags:
- "v*"
permissions: {}
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@{{STANDARDS_SHA}} # {{STANDARDS_VERSION}}
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations; Azure OIDC when signing
attestations: write # store attestations
with:
solution: "{{SOLUTION}}"
app-project: "{{APP_PROJECT}}"
app-id: "{{APP_ID}}"
main-exe: "{{MAIN_EXE}}"
icon: "{{ICON}}"
platform: x64
runtime: win-x64
coverage-packages: "{{COVERAGE_PACKAGES}}"
test-projects: |
{{TEST_PROJECT}}
cli-project: "{{CLI_PROJECT}}" # remove for apps without a CLI
sign: false # exception EX-0002: true once the Azure Trusted Signing identity is validated
# winget lives here, not in the shared workflow, because its token belongs to this
# repository's `release` environment. It runs only for stable releases, and only when
# the WINGET_PACKAGE_ID variable is set (after the manual first submission).
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 }}
run: |
if (-not $env:WINGET_TOKEN)
{
Write-Output '::notice::No WINGET_TOKEN in the release environment; skipping winget.'
exit 0
}
# wingetcreate v1.12.13.0, pinned by URL and verified by SHA-256 before use.
$wingetcreate = Join-Path $env:RUNNER_TEMP 'wingetcreate.exe'
Invoke-WebRequest -Uri 'https://github.com/microsoft/winget-create/releases/download/v1.12.13.0/wingetcreate.exe' -OutFile $wingetcreate
if ((Get-FileHash -LiteralPath $wingetcreate -Algorithm SHA256).Hash -ne '24042BD37915805615E6CF969AC57C6439124C3FE85823327F5F3FB24BD9FFEA')
{
Write-Output '::error::wingetcreate.exe does not match the pinned SHA-256.'
exit 1
}
& $wingetcreate update $env:WINGET_PACKAGE_ID --urls $env:SETUP_URL --version $env:K22_VERSION --submit --token $env:WINGET_TOKEN