diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 30b04673..e5d81cad 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -456,6 +456,54 @@ jobs: archive: false + pkg-winget: + if: startsWith(github.ref, 'refs/tags/') + runs-on: windows-2025 + needs: [get-version, pkg-msi] + steps: + - uses: actions/checkout@main + - uses: actions/download-artifact@main + with: + name: "snclient-${{needs.get-version.outputs.version}}-windows-i386.msi" + path: "winget-msi/i386" + - uses: actions/download-artifact@main + with: + name: "snclient-${{needs.get-version.outputs.version}}-windows-x86_64.msi" + path: "winget-msi/x86_64" + - uses: actions/download-artifact@main + with: + name: "snclient-${{needs.get-version.outputs.version}}-windows-aarch64.msi" + path: "winget-msi/aarch64" + - name: "Generate and validate WinGet manifests" + shell: pwsh + run: | + $version = "${{ needs.get-version.outputs.version }}" + $packageVersion = "${{ needs.get-version.outputs.major }}.${{ needs.get-version.outputs.minor }}.${{ needs.get-version.outputs.revision }}" + $msiX86 = "winget-msi/i386/snclient-$version-windows-i386.msi" + $msiX64 = "winget-msi/x86_64/snclient-$version-windows-x86_64.msi" + $msiArm64 = "winget-msi/aarch64/snclient-$version-windows-aarch64.msi" + $releaseDate = git log -1 --format=%cs + & .\buildtools\winget_manifests.ps1 ` + -PackageVersion $packageVersion ` + -TagVersion $version ` + -ReleaseDate $releaseDate ` + -MsiX64 $msiX64 ` + -MsiX86 $msiX86 ` + -MsiArm64 $msiArm64 ` + -OutDir "packaging\winget\$packageVersion" + if ($LASTEXITCODE -ne 0) { exit 1 } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { throw 'winget is not available.' } + winget validate --manifest "packaging\winget\$packageVersion" + if ($LASTEXITCODE -ne 0) { exit 1 } + Compress-Archive -Path "packaging\winget\$packageVersion\*" -DestinationPath "snclient-$version-winget-manifests.zip" -Force + - uses: actions/upload-artifact@main + with: + name: "winget-manifests" + path: "snclient-${{ needs.get-version.outputs.version }}-winget-manifests.zip" + if-no-files-found: error + archive: false + + pkg-deb: strategy: fail-fast: false @@ -860,7 +908,7 @@ jobs: make-release: if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-26.04 - needs: [get-version,pkg-msi,pkg-deb,pkg-rpm,pkg-osx,integration-test-linux,integration-test-windows, integration-test-osx, test-linux, test-win, test-osx, test-bsd, test-common, test-bench, test-cross, test-docs] + needs: [get-version,pkg-msi,pkg-winget,pkg-deb,pkg-rpm,pkg-osx,integration-test-linux,integration-test-windows, integration-test-osx, test-linux, test-win, test-osx, test-bsd, test-common, test-bench, test-cross, test-docs] permissions: contents: write outputs: @@ -899,6 +947,21 @@ jobs: - run: gh release upload v${{needs.get-version.outputs.version}} ${{ env.BIN }}.msi + upload-winget-manifests: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-26.04 + needs: [get-version, make-release, pkg-winget] + permissions: + contents: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/download-artifact@main + with: + name: "winget-manifests" + - run: gh release upload v${{needs.get-version.outputs.version}} snclient-${{needs.get-version.outputs.version}}-winget-manifests.zip + + upload-linux-deb-assets: if: startsWith(github.ref, 'refs/tags/') strategy: diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 00000000..a3aeb988 --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,187 @@ +name: Publish to WinGet + +# This workflow publishes the generated WinGet manifests to microsoft/winget-pkgs. +# It does not generate the manifests. +# The release workflow attaches the manifest zip to the release. +# The API creates the commit and PR in the fork without cloning the repository. +# Set the repository variable WINGET_FORK_OWNER to the account that owns the fork. +# Set the repository variable WINGET_AUTOPUBLISH to true after the first submission. +# The WINGET_TOKEN secret needs a classic PAT with public_repo scope. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Existing release tag to submit (for example v1.02)" + required: true + type: string + dry_run: + description: "Print the manifests and do not create a PR" + required: false + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: winget-${{ github.event.release.tag_name || inputs.release_tag }} + cancel-in-progress: false + +jobs: + publish: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && !github.event.release.prerelease && vars.WINGET_AUTOPUBLISH == 'true') + runs-on: windows-2025 + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + WINGET_FORK_OWNER: ${{ vars.WINGET_FORK_OWNER }} + steps: + - name: "Download release manifests" + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tag = $env:RELEASE_TAG + if (-not $tag) { throw 'No release tag was provided.' } + $tagVersion = $tag -replace '^v', '' + $downloadDir = Join-Path $env:RUNNER_TEMP 'winget-download' + $manifestDir = Join-Path $env:RUNNER_TEMP 'winget-manifests' + New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null + New-Item -ItemType Directory -Force -Path $manifestDir | Out-Null + & gh release download $tag --repo $env:GH_REPO --pattern "snclient-$tagVersion-winget-manifests.zip" --dir $downloadDir + if ($LASTEXITCODE -ne 0) { throw "Could not download the manifest asset for $tag." } + $zip = Get-ChildItem -Path $downloadDir -Filter '*.zip' | Select-Object -First 1 + if (-not $zip) { throw 'The manifest zip is missing.' } + Expand-Archive -Path $zip.FullName -DestinationPath $manifestDir -Force + Get-ChildItem -Path $manifestDir -Filter '*.yaml' | Select-Object Name, Length + + - name: "Dry run" + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} + shell: pwsh + run: | + $manifestDir = Join-Path $env:RUNNER_TEMP 'winget-manifests' + Get-ChildItem -Path $manifestDir -Filter '*.yaml' | ForEach-Object { + Write-Output "===== $($_.Name) =====" + Get-Content -LiteralPath $_.FullName + } + + - name: "Publish manifests" + if: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run != true }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $manifestDir = Join-Path $env:RUNNER_TEMP 'winget-manifests' + $forkOwner = $env:WINGET_FORK_OWNER + if (-not $forkOwner) { throw 'Set the WINGET_FORK_OWNER repository variable.' } + $repo = 'microsoft/winget-pkgs' + $forkRepo = "$forkOwner/winget-pkgs" + + function Invoke-GhJson { + param([string[]]$Arguments) + $output = & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "gh api failed: $($Arguments -join ' ')" + } + if (-not $output) { return $null } + return ($output | ConvertFrom-Json) + } + + $versionFile = Join-Path $manifestDir 'ConSol.SNClient.yaml' + if (-not (Test-Path -LiteralPath $versionFile)) { throw 'ConSol.SNClient.yaml is missing.' } + $versionMatch = Select-String -Path $versionFile -Pattern '^PackageVersion:\s*(.+)$' + if (-not $versionMatch) { throw 'PackageVersion is missing.' } + $packageVersion = $versionMatch.Matches[0].Groups[1].Value.Trim() + $identifierMatch = Select-String -Path $versionFile -Pattern '^PackageIdentifier:\s*(.+)$' + if (-not $identifierMatch) { throw 'PackageIdentifier is missing.' } + $packageIdentifier = $identifierMatch.Matches[0].Groups[1].Value.Trim() + if ($packageIdentifier -ne 'ConSol.SNClient') { throw "Unexpected package identifier: $packageIdentifier" } + + Invoke-GhJson -Arguments @('api', "repos/$forkRepo") | Out-Null + + & gh api "repos/$repo/contents/manifests/c/ConSol/SNClient/$packageVersion" 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Output "Version $packageVersion already exists in winget-pkgs." + exit 0 + } + + & gh api "repos/$repo/contents/manifests/c/ConSol/SNClient" 2>$null | Out-Null + $isNewPackage = $LASTEXITCODE -ne 0 + $title = if ($isNewPackage) { "New package: $packageIdentifier $packageVersion" } else { "Update $packageIdentifier to $packageVersion" } + $message = if ($isNewPackage) { "New package: $packageIdentifier $packageVersion" } else { "Update $packageIdentifier to $packageVersion" } + + $upstreamRef = Invoke-GhJson -Arguments @('api', "repos/$repo/git/ref/heads/master") + $forkRef = Invoke-GhJson -Arguments @('api', "repos/$forkRepo/git/ref/heads/master") + if ($forkRef.object.sha -ne $upstreamRef.object.sha) { + $syncPayload = @{ branch = 'master' } | ConvertTo-Json + $syncFile = Join-Path $env:RUNNER_TEMP 'winget-sync.json' + [IO.File]::WriteAllText($syncFile, $syncPayload, [Text.UTF8Encoding]::new($false)) + & gh api --method POST "repos/$forkRepo/merge-upstream" --input $syncFile 2>$null | Out-Null + $forkRef = Invoke-GhJson -Arguments @('api', "repos/$forkRepo/git/ref/heads/master") + } + if ($forkRef.object.sha -ne $upstreamRef.object.sha) { + throw 'The winget-pkgs fork is not current. Sync it and retry.' + } + $parentSha = $forkRef.object.sha + $forkCommit = Invoke-GhJson -Arguments @('api', "repos/$forkRepo/git/commits/$parentSha") + $baseTree = $forkCommit.tree.sha + + $tempDir = Join-Path $env:RUNNER_TEMP 'winget-pr' + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $utf8 = New-Object System.Text.UTF8Encoding($false) + $entries = @() + Get-ChildItem -Path $manifestDir -Filter '*.yaml' | ForEach-Object { + $content = [Convert]::ToBase64String([IO.File]::ReadAllBytes($_.FullName)) + $blobPayload = @{ content = $content; encoding = 'base64' } | ConvertTo-Json -Compress + $blobFile = Join-Path $tempDir ("blob-" + $_.Name + ".json") + [IO.File]::WriteAllText($blobFile, $blobPayload, $utf8) + $blob = Invoke-GhJson -Arguments @('api', '--method', 'POST', "repos/$forkRepo/git/blobs", '--input', $blobFile) + $entries += @{ + path = "manifests/c/ConSol/SNClient/$packageVersion/$($_.Name)" + mode = '100644' + type = 'blob' + sha = $blob.sha + } + } + + $treePayload = @{ base_tree = $baseTree; tree = $entries } | ConvertTo-Json -Depth 6 + $treeFile = Join-Path $tempDir 'tree.json' + [IO.File]::WriteAllText($treeFile, $treePayload, $utf8) + $tree = Invoke-GhJson -Arguments @('api', '--method', 'POST', "repos/$forkRepo/git/trees", '--input', $treeFile) + + $commitPayload = @{ + message = $message + tree = $tree.sha + parents = @($parentSha) + } | ConvertTo-Json -Depth 4 + $commitFile = Join-Path $tempDir 'commit.json' + [IO.File]::WriteAllText($commitFile, $commitPayload, $utf8) + $commit = Invoke-GhJson -Arguments @('api', '--method', 'POST', "repos/$forkRepo/git/commits", '--input', $commitFile) + + $branch = "consol-snclient-$packageVersion-$env:GITHUB_RUN_ID" + $refPayload = @{ ref = "refs/heads/$branch"; sha = $commit.sha } | ConvertTo-Json + $refFile = Join-Path $tempDir 'ref.json' + [IO.File]::WriteAllText($refFile, $refPayload, $utf8) + Invoke-GhJson -Arguments @('api', '--method', 'POST', "repos/$forkRepo/git/refs", '--input', $refFile) | Out-Null + + $body = @" + $title + + SNClient is a general-purpose monitoring agent. + The installer is a per-machine WiX MSI. + It installs the snclient Windows service and a firewall rule. + ElevationRequirement is elevatesSelf because elevationRequired triggers microsoft/winget-cli#2231. + InstallationMetadata runs snclient.exe --version for executable discovery. + "@ + $prPayload = @{ + title = $title + head = "${forkOwner}:${branch}" + base = 'master' + body = $body + } | ConvertTo-Json -Depth 4 + $prFile = Join-Path $tempDir 'pr.json' + [IO.File]::WriteAllText($prFile, $prPayload, $utf8) + $pr = Invoke-GhJson -Arguments @('api', '--method', 'POST', "repos/$repo/pulls", '--input', $prFile) + Write-Output "Created PR $($pr.html_url)" diff --git a/Makefile b/Makefile index 7f92f965..1e046ea2 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,10 @@ export PATH := $(GOBIN):$(PATH) GOLANG_CI_OPTIONS=--show-stats=0 --uniq-by-line=0 --timeout=5m VERSION ?= $(shell ./buildtools/get_version) +WINGET_PACKAGE_VERSION ?= $(shell ./buildtools/get_winget_version) +WINGET_TAG_VERSION ?= $(VERSION) +WINGET_RELEASE_DATE ?= $(shell git log -1 --format=%cs) +WINGET_OUTDIR ?= $(if $(WINGET_PACKAGE_VERSION),packaging/winget/$(WINGET_PACKAGE_VERSION),) GOARCH ?= $(shell go env GOARCH) GOOS ?= $(shell go env GOOS) @@ -117,7 +121,7 @@ endif ENTR=ls cmd/*/*.go pkg/*/*.go pkg/*/*/*.go snclient*.ini | entr -sr -.PHONY=docs +.PHONY=docs winget-manifests all: build snclient.ini server.crt server.key @@ -363,6 +367,7 @@ clean: rm -rf $(TOOLSFOLDER) rm -rf dist/ rm -rf windist/ + rm -rf packaging/winget/[0-9]*.[0-9]*.[0-9]* rm -rf build-deb/ rm -rf build-rpm/ rm -f release_notes.txt @@ -473,6 +478,20 @@ windist: | dist windist/snclient.ini todos windist/snclient.ini +# Generate the WinGet manifests for the current version. +winget-manifests: + WINGET_PACKAGE_VERSION="$(WINGET_PACKAGE_VERSION)" \ + WINGET_TAG_VERSION="$(WINGET_TAG_VERSION)" \ + WINGET_RELEASE_DATE="$(WINGET_RELEASE_DATE)" \ + WINGET_OUTDIR="$(WINGET_OUTDIR)" \ + WINGET_SHA256_X64="$(WINGET_SHA256_X64)" \ + WINGET_SHA256_X86="$(WINGET_SHA256_X86)" \ + WINGET_SHA256_ARM64="$(WINGET_SHA256_ARM64)" \ + WINGET_PRODUCTCODE_X64="$(WINGET_PRODUCTCODE_X64)" \ + WINGET_PRODUCTCODE_X86="$(WINGET_PRODUCTCODE_X86)" \ + WINGET_PRODUCTCODE_ARM64="$(WINGET_PRODUCTCODE_ARM64)" \ + ./buildtools/winget_manifests.sh + snclient: build snclient.ini diff --git a/buildtools/get_winget_version b/buildtools/get_winget_version new file mode 100755 index 00000000..429959fb --- /dev/null +++ b/buildtools/get_winget_version @@ -0,0 +1,21 @@ +#!/bin/sh + +# Print the WinGet package version. +# This version comes from the MSI ProductVersion and ARP DisplayVersion. +# The WinGet PackageVersion must match the installed MSI version. + +set -eu + +SCRIPT_DIR=$(CDPATH='' cd "$(dirname "$0")" && pwd) +ROOT=$(dirname "$SCRIPT_DIR") +VERSION=$(cd "$ROOT" && ./buildtools/get_version) +PARTS=$(printf '%s' "$VERSION" | awk -F. '{ print NF }') + +if [ "$PARTS" -eq 2 ]; then + VERSION="${VERSION}.0" +elif [ "$PARTS" -ne 3 ]; then + echo "unsupported version: $VERSION" >&2 + exit 1 +fi + +echo "$VERSION" diff --git a/buildtools/winget_manifests.ps1 b/buildtools/winget_manifests.ps1 new file mode 100644 index 00000000..5b61a991 --- /dev/null +++ b/buildtools/winget_manifests.ps1 @@ -0,0 +1,158 @@ +[CmdletBinding()] +param( + [string]$PackageVersion, + [string]$TagVersion, + [string]$ReleaseDate, + [string]$OutDir, + [string]$TemplateDir, + [string]$MsiX64, + [string]$MsiX86, + [string]$MsiArm64, + [string]$Sha256X64, + [string]$Sha256X86, + [string]$Sha256Arm64, + [string]$ProductCodeX64, + [string]$ProductCodeX86, + [string]$ProductCodeArm64 +) + +# Generate WinGet manifests from the templates. +# This is the PowerShell counterpart of buildtools/winget_manifests.sh. +# Keep both scripts in sync. +# TAG_VERSION is the release tag without the leading "v". +# PACKAGE_VERSION is the MSI ProductVersion and ARP DisplayVersion. +# WinGet PackageVersion must match the installed MSI version. +# Installer URLs use TAG_VERSION. +# PackageVersion uses PACKAGE_VERSION. + +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSScriptRoot +if (-not $TemplateDir) { + $TemplateDir = Join-Path $RepoRoot 'packaging/winget/templates' +} +if (-not $ReleaseDate) { + $ReleaseDate = Get-Date -Format 'yyyy-MM-dd' +} + +function Get-MsiMetadata { + param([string]$Path) + + $fullPath = (Resolve-Path -LiteralPath $Path).Path + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($fullPath, 0) + $view = $database.OpenView("SELECT Property, Value FROM Property") + $view.Execute() + $properties = @{} + try { + while ($record = $view.Fetch()) { + $properties[$record.StringData(1)] = $record.StringData(2) + [void][Runtime.InteropServices.Marshal]::ReleaseComObject($record) + } + } finally { + $view.Close() + [void][Runtime.InteropServices.Marshal]::ReleaseComObject($view) + [void][Runtime.InteropServices.Marshal]::ReleaseComObject($database) + [void][Runtime.InteropServices.Marshal]::ReleaseComObject($installer) + } + + [pscustomobject]@{ + ProductCode = $properties['ProductCode'] + ProductVersion = $properties['ProductVersion'] + Sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash + } +} + +$msiData = @{} +if ($MsiX64) { + $msiData['X64'] = Get-MsiMetadata -Path $MsiX64 + $Sha256X64 = $msiData['X64'].Sha256 + $ProductCodeX64 = $msiData['X64'].ProductCode +} +if ($MsiX86) { + $msiData['X86'] = Get-MsiMetadata -Path $MsiX86 + $Sha256X86 = $msiData['X86'].Sha256 + $ProductCodeX86 = $msiData['X86'].ProductCode +} +if ($MsiArm64) { + $msiData['ARM64'] = Get-MsiMetadata -Path $MsiArm64 + $Sha256Arm64 = $msiData['ARM64'].Sha256 + $ProductCodeArm64 = $msiData['ARM64'].ProductCode +} + +$msiVersions = @($msiData.Values | ForEach-Object { $_.ProductVersion } | Select-Object -Unique) +if ($msiVersions.Count -gt 1) { + throw "MSI versions differ: $($msiVersions -join ', ')" +} +if (-not $PackageVersion) { + if ($msiVersions.Count -eq 1) { + $PackageVersion = $msiVersions[0] + } else { + throw 'PackageVersion is required when no MSI path is given.' + } +} +if ($msiVersions.Count -eq 1 -and $PackageVersion -ne $msiVersions[0]) { + throw "PackageVersion $PackageVersion does not match MSI version $($msiVersions[0])." +} +if (-not $TagVersion) { + throw 'TagVersion is required.' +} + +foreach ($value in @($Sha256X64, $Sha256X86, $Sha256Arm64)) { + if (-not $value) { + throw 'All SHA256 values are required.' + } + if ($value -notmatch '^[0-9A-Fa-f]{64}$') { + throw "Invalid SHA256 value: $value" + } +} +foreach ($value in @($ProductCodeX64, $ProductCodeX86, $ProductCodeArm64)) { + if (-not $value) { + throw 'All ProductCode values are required.' + } + if ($value -notmatch '^\{[0-9A-Fa-f-]+\}$') { + throw "Invalid ProductCode value: $value" + } +} +if ($PackageVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "Invalid package version: $PackageVersion" +} +if ($TagVersion -notmatch '^\d+\.\d+(\.\d+)?$') { + throw "Invalid tag version: $TagVersion" +} +if ($ReleaseDate -notmatch '^\d{4}-\d{2}-\d{2}$') { + throw "Invalid release date: $ReleaseDate" +} +if (-not $OutDir) { + $OutDir = Join-Path $RepoRoot "packaging/winget/$PackageVersion" +} + +$replacements = [ordered]@{ + '__PACKAGE_VERSION__' = $PackageVersion + '__TAG_VERSION__' = $TagVersion + '__RELEASE_DATE__' = $ReleaseDate + '__SHA256_X64__' = $Sha256X64 + '__SHA256_X86__' = $Sha256X86 + '__SHA256_ARM64__' = $Sha256Arm64 + '__PRODUCT_CODE_X64__' = $ProductCodeX64 + '__PRODUCT_CODE_X86__' = $ProductCodeX86 + '__PRODUCT_CODE_ARM64__' = $ProductCodeArm64 +} + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$templates = Get-ChildItem -LiteralPath $TemplateDir -Filter '*.yaml.in' +if ($templates.Count -eq 0) { + throw "No templates found in $TemplateDir" +} +$utf8 = New-Object System.Text.UTF8Encoding($false) +foreach ($template in $templates) { + $content = Get-Content -LiteralPath $template.FullName -Raw + foreach ($key in $replacements.Keys) { + $content = $content.Replace($key, $replacements[$key]) + } + if ($content -match '__[A-Z0-9_]+__') { + throw "Unresolved placeholder in $($template.Name)" + } + $output = Join-Path $OutDir ($template.Name -replace '\.in$', '') + [IO.File]::WriteAllText($output, $content, $utf8) + Write-Output "generated $output" +} diff --git a/buildtools/winget_manifests.sh b/buildtools/winget_manifests.sh new file mode 100755 index 00000000..ee8ca3fd --- /dev/null +++ b/buildtools/winget_manifests.sh @@ -0,0 +1,133 @@ +#!/bin/sh + +# Generate WinGet manifests from the templates. +# The Makefile uses this script. +# The PowerShell counterpart is buildtools/winget_manifests.ps1. +# Keep both scripts in sync. +# TAG_VERSION is the release tag without the leading "v". +# PACKAGE_VERSION is the MSI ProductVersion and ARP DisplayVersion. +# WinGet PackageVersion must match the installed MSI version. +# Installer URLs use TAG_VERSION. +# PackageVersion uses PACKAGE_VERSION. + +set -eu + +SCRIPT_DIR=$(CDPATH='' cd "$(dirname "$0")" && pwd) +ROOT=$(dirname "$SCRIPT_DIR") +TEMPLATES=${WINGET_TEMPLATES:-$ROOT/packaging/winget/templates} + +PACKAGE_VERSION=${WINGET_PACKAGE_VERSION:-} +TAG_VERSION=${WINGET_TAG_VERSION:-} +RELEASE_DATE=${WINGET_RELEASE_DATE:-} +OUTDIR=${WINGET_OUTDIR:-} + +if [ -z "$PACKAGE_VERSION" ]; then + PACKAGE_VERSION=$("$SCRIPT_DIR/get_winget_version") +fi +if [ -z "$TAG_VERSION" ]; then + TAG_VERSION=$(cd "$ROOT" && ./buildtools/get_version) +fi +if [ -z "$RELEASE_DATE" ]; then + RELEASE_DATE=$(date -u +%Y-%m-%d) +fi +if [ -z "$OUTDIR" ]; then + OUTDIR="$ROOT/packaging/winget/$PACKAGE_VERSION" +fi + +SHA256_X64=${WINGET_SHA256_X64:-} +SHA256_X86=${WINGET_SHA256_X86:-} +SHA256_ARM64=${WINGET_SHA256_ARM64:-} +PRODUCT_CODE_X64=${WINGET_PRODUCTCODE_X64:-} +PRODUCT_CODE_X86=${WINGET_PRODUCTCODE_X86:-} +PRODUCT_CODE_ARM64=${WINGET_PRODUCTCODE_ARM64:-} + +require_value() { + name=$1 + value=$2 + if [ -z "$value" ]; then + echo "missing $name" >&2 + exit 1 + fi +} + +require_hash() { + name=$1 + value=$2 + if [ "${#value}" -ne 64 ]; then + echo "$name must be a 64 character SHA256 hash" >&2 + exit 1 + fi + case "$value" in + *[!0-9A-Fa-f]*) + echo "$name must contain only hexadecimal characters" >&2 + exit 1 + ;; + esac +} + +require_product_code() { + name=$1 + value=$2 + case "$value" in + '{'*'}') ;; + *) + echo "$name must be a braced MSI ProductCode" >&2 + exit 1 + ;; + esac +} + +require_value WINGET_SHA256_X64 "$SHA256_X64" +require_value WINGET_SHA256_X86 "$SHA256_X86" +require_value WINGET_SHA256_ARM64 "$SHA256_ARM64" +require_value WINGET_PRODUCTCODE_X64 "$PRODUCT_CODE_X64" +require_value WINGET_PRODUCTCODE_X86 "$PRODUCT_CODE_X86" +require_value WINGET_PRODUCTCODE_ARM64 "$PRODUCT_CODE_ARM64" + +require_hash WINGET_SHA256_X64 "$SHA256_X64" +require_hash WINGET_SHA256_X86 "$SHA256_X86" +require_hash WINGET_SHA256_ARM64 "$SHA256_ARM64" +require_product_code WINGET_PRODUCTCODE_X64 "$PRODUCT_CODE_X64" +require_product_code WINGET_PRODUCTCODE_X86 "$PRODUCT_CODE_X86" +require_product_code WINGET_PRODUCTCODE_ARM64 "$PRODUCT_CODE_ARM64" + +case "$PACKAGE_VERSION" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) + echo "invalid package version: $PACKAGE_VERSION" >&2 + exit 1 + ;; +esac +case "$TAG_VERSION" in + [0-9]*.[0-9]*) ;; + *) + echo "invalid tag version: $TAG_VERSION" >&2 + exit 1 + ;; +esac + +mkdir -p "$OUTDIR" + +for template in "$TEMPLATES"/*.yaml.in; do + if [ ! -e "$template" ]; then + echo "no templates found in $TEMPLATES" >&2 + exit 1 + fi + output="$OUTDIR/$(basename "$template" .in)" + sed \ + -e "s|__PACKAGE_VERSION__|$PACKAGE_VERSION|g" \ + -e "s|__TAG_VERSION__|$TAG_VERSION|g" \ + -e "s|__RELEASE_DATE__|$RELEASE_DATE|g" \ + -e "s|__SHA256_X64__|$SHA256_X64|g" \ + -e "s|__SHA256_X86__|$SHA256_X86|g" \ + -e "s|__SHA256_ARM64__|$SHA256_ARM64|g" \ + -e "s|__PRODUCT_CODE_X64__|$PRODUCT_CODE_X64|g" \ + -e "s|__PRODUCT_CODE_X86__|$PRODUCT_CODE_X86|g" \ + -e "s|__PRODUCT_CODE_ARM64__|$PRODUCT_CODE_ARM64|g" \ + "$template" > "$output" + if grep -q '__[A-Z0-9_]*__' "$output"; then + echo "unresolved placeholder in $output" >&2 + exit 1 + fi + echo "generated $output" +done diff --git a/packaging/RELEASE.md b/packaging/RELEASE.md index 8058509d..5a057eeb 100644 --- a/packaging/RELEASE.md +++ b/packaging/RELEASE.md @@ -22,3 +22,32 @@ To build a new release: The workflow for the git tag also creates a draft release. When the workflow finishes, open the [releases page](https://github.com/ConSol-Monitoring/snclient/releases). Edit the draft for the latest tag. Scroll down and publish the release. + +## Winget + +The release workflow generates the WinGet manifests. +It validates the manifests with `winget validate`. +It attaches `snclient--winget-manifests.zip` to the draft release. +Review the generated manifests before you publish the release. +The first submission is manual. +Generate the v1.01.0 manifests from the three release MSIs. +The generated files are in `packaging/winget//`. +Copy the three YAML files to `microsoft/winget-pkgs`. +Use the path `manifests/c/ConSol/SNClient//`. +Open one pull request for one package version. +For later releases, copy the three YAML files from the release zip. + +After the first package is merged, enable automation. +Create a fork of `microsoft/winget-pkgs` under the fork owner. +Set the `WINGET_FORK_OWNER` repository variable to the fork owner. +Add a classic PAT with `public_repo` as the `WINGET_TOKEN` secret. +The PAT owner must have write access to the fork. +The workflow uses the GitHub API and does not clone the fork. +Keep `WINGET_AUTOPUBLISH` false until the first package is merged. +Set `WINGET_AUTOPUBLISH` to true after the merge. +The publish workflow creates the next winget-pkgs pull request. + +Run `make winget-manifests` to generate the manifests locally. +Pass the hash and ProductCode variables from `buildtools/winget_manifests.sh`. +On Windows, run `pwsh buildtools/winget_manifests.ps1` with the three MSI paths. +The PowerShell script reads the ProductCode and ProductVersion from the MSIs. diff --git a/packaging/winget/.gitignore b/packaging/winget/.gitignore new file mode 100644 index 00000000..8ee799a7 --- /dev/null +++ b/packaging/winget/.gitignore @@ -0,0 +1,2 @@ +# Generated release directories are ignored. +/[0-9]*.[0-9]*.[0-9]*/ diff --git a/packaging/winget/ConSol.SNClient.installer.yaml b/packaging/winget/templates/ConSol.SNClient.installer.yaml.in similarity index 69% rename from packaging/winget/ConSol.SNClient.installer.yaml rename to packaging/winget/templates/ConSol.SNClient.installer.yaml.in index 47e622c3..55032917 100644 --- a/packaging/winget/ConSol.SNClient.installer.yaml +++ b/packaging/winget/templates/ConSol.SNClient.installer.yaml.in @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json PackageIdentifier: ConSol.SNClient -PackageVersion: 1.01.0 +PackageVersion: __PACKAGE_VERSION__ InstallerLocale: en-US InstallerType: wix Scope: machine @@ -13,16 +13,16 @@ InstallModes: InstallerSwitches: InstallLocation: INSTALLDIR="" UpgradeBehavior: install -ReleaseDate: 2026-10-29 +ReleaseDate: __RELEASE_DATE__ Installers: - Architecture: x64 - InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v1.01/snclient-1.01-windows-x86_64.msi - InstallerSha256: D2FD1F79B9FAFE3E20FF623F9F106D456686AEE5C9817E397BCBC785F6186BEF - ProductCode: '{E7699464-7A6A-434D-B4BC-9D903A71FC6A}' + InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v__TAG_VERSION__/snclient-__TAG_VERSION__-windows-x86_64.msi + InstallerSha256: __SHA256_X64__ + ProductCode: '__PRODUCT_CODE_X64__' AppsAndFeaturesEntries: - DisplayName: SNClient Publisher: ConSol Consulting and Solutions Software GmbH - ProductCode: '{E7699464-7A6A-434D-B4BC-9D903A71FC6A}' + ProductCode: '__PRODUCT_CODE_X64__' UpgradeCode: '{F4BF03F6-D3DA-44FF-A444-9B64119DA0CE}' InstallerType: wix InstallationMetadata: @@ -32,13 +32,13 @@ Installers: FileType: launch InvocationParameter: --version - Architecture: x86 - InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v1.01/snclient-1.01-windows-i386.msi - InstallerSha256: 913CFC95074E5BC028854E8E92EEA4B8507C3312144ACDC176ADD82C69AC150E - ProductCode: '{AF073BD8-4769-4184-802C-0BEF3C963880}' + InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v__TAG_VERSION__/snclient-__TAG_VERSION__-windows-i386.msi + InstallerSha256: __SHA256_X86__ + ProductCode: '__PRODUCT_CODE_X86__' AppsAndFeaturesEntries: - DisplayName: SNClient Publisher: ConSol Consulting and Solutions Software GmbH - ProductCode: '{AF073BD8-4769-4184-802C-0BEF3C963880}' + ProductCode: '__PRODUCT_CODE_X86__' UpgradeCode: '{F4BF03F6-D3DA-44FF-A444-9B64119DA0CE}' InstallerType: wix InstallationMetadata: @@ -48,13 +48,13 @@ Installers: FileType: launch InvocationParameter: --version - Architecture: arm64 - InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v1.01/snclient-1.01-windows-aarch64.msi - InstallerSha256: 1AB501A4CCFD0BA489A677FDDF1AB4119EA6E1BAB0F23CDFBD628C0633E03814 - ProductCode: '{EEC19E7A-6A5B-46B8-AAEA-9DD787635766}' + InstallerUrl: https://github.com/ConSol-Monitoring/snclient/releases/download/v__TAG_VERSION__/snclient-__TAG_VERSION__-windows-aarch64.msi + InstallerSha256: __SHA256_ARM64__ + ProductCode: '__PRODUCT_CODE_ARM64__' AppsAndFeaturesEntries: - DisplayName: SNClient Publisher: ConSol Consulting and Solutions Software GmbH - ProductCode: '{EEC19E7A-6A5B-46B8-AAEA-9DD787635766}' + ProductCode: '__PRODUCT_CODE_ARM64__' UpgradeCode: '{F4BF03F6-D3DA-44FF-A444-9B64119DA0CE}' InstallerType: wix InstallationMetadata: diff --git a/packaging/winget/ConSol.SNClient.locale.en-US.yaml b/packaging/winget/templates/ConSol.SNClient.locale.en-US.yaml.in similarity index 96% rename from packaging/winget/ConSol.SNClient.locale.en-US.yaml rename to packaging/winget/templates/ConSol.SNClient.locale.en-US.yaml.in index 42f2595b..c1c1830c 100644 --- a/packaging/winget/ConSol.SNClient.locale.en-US.yaml +++ b/packaging/winget/templates/ConSol.SNClient.locale.en-US.yaml.in @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json PackageIdentifier: ConSol.SNClient -PackageVersion: 1.01.0 +PackageVersion: __PACKAGE_VERSION__ PackageLocale: en-US Publisher: ConSol Consulting and Solutions Software GmbH PublisherUrl: https://www.consol.com/ @@ -36,7 +36,7 @@ InstallationNotes: |- C:\Program Files\snclient\snclient.ini and restart the snclient service. Use winget or msiexec to update MSI installations; "snclient.exe update" only replaces the binary and does not update the MSI/ARP version. -ReleaseNotesUrl: https://github.com/ConSol-Monitoring/snclient/releases/tag/v1.01 +ReleaseNotesUrl: https://github.com/ConSol-Monitoring/snclient/releases/tag/v__TAG_VERSION__ Documentations: - DocumentLabel: Documentation DocumentUrl: https://omd.consol.de/docs/snclient/ diff --git a/packaging/winget/ConSol.SNClient.yaml b/packaging/winget/templates/ConSol.SNClient.yaml.in similarity index 84% rename from packaging/winget/ConSol.SNClient.yaml rename to packaging/winget/templates/ConSol.SNClient.yaml.in index 25471abc..e7cc9449 100644 --- a/packaging/winget/ConSol.SNClient.yaml +++ b/packaging/winget/templates/ConSol.SNClient.yaml.in @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json PackageIdentifier: ConSol.SNClient -PackageVersion: 1.01.0 +PackageVersion: __PACKAGE_VERSION__ DefaultLocale: en-US ManifestType: version ManifestVersion: 1.12.0