Skip to content

Commit 8901150

Browse files
committed
Add winget and Chocolatey publish automation
Add automated publish jobs to the release workflow and supporting scripts/docs. .github/workflows/release.yml gains publish-winget and publish-chocolatey jobs that gate on repository secrets, download release artifacts, and either submit a winget-pkgs PR or push a Chocolatey package when the release succeeds. Added build/submit-winget-pr.ps1 to create a fork branch, add manifests and open a PR to microsoft/winget-pkgs, and build/publish-chocolatey.ps1 to pack and push a Chocolatey nupkg (handling idempotent already-exists cases). README.md and docs/release/PACKAGING.md updated to document the new automation and required secrets (WINGET_GITHUB_TOKEN, WINGET_FORK_OWNER, CHOCOLATEY_API_KEY).
1 parent a279062 commit 8901150

5 files changed

Lines changed: 343 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,3 +443,107 @@ jobs:
443443
winget-manifests/**/*.yaml
444444
sbom/manifest.spdx.json
445445
generate_release_notes: true
446+
447+
publish-winget:
448+
runs-on: windows-latest
449+
needs:
450+
- build
451+
- release
452+
if: needs.release.result == 'success'
453+
454+
steps:
455+
- name: Check winget publish secrets
456+
id: gate
457+
shell: pwsh
458+
env:
459+
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
460+
WINGET_FORK_OWNER: ${{ secrets.WINGET_FORK_OWNER }}
461+
run: |
462+
if ([string]::IsNullOrWhiteSpace($env:WINGET_GITHUB_TOKEN) -or [string]::IsNullOrWhiteSpace($env:WINGET_FORK_OWNER))
463+
{
464+
Write-Host "Winget publish secrets missing. Skipping publish-winget job."
465+
"enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
466+
exit 0
467+
}
468+
469+
"enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
470+
471+
- name: Checkout
472+
if: steps.gate.outputs.enabled == 'true'
473+
uses: actions/checkout@v4
474+
475+
- name: Download winget manifests
476+
if: steps.gate.outputs.enabled == 'true'
477+
uses: actions/download-artifact@v4
478+
with:
479+
name: winget-manifests
480+
path: winget-manifests
481+
482+
- name: Submit winget PR
483+
if: steps.gate.outputs.enabled == 'true'
484+
shell: pwsh
485+
env:
486+
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
487+
WINGET_FORK_OWNER: ${{ secrets.WINGET_FORK_OWNER }}
488+
run: |
489+
$ErrorActionPreference = "Stop"
490+
./build/submit-winget-pr.ps1 `
491+
-Version "${{ needs.build.outputs.version }}" `
492+
-ManifestSourcePath "winget-manifests" `
493+
-ForkOwner "$env:WINGET_FORK_OWNER" `
494+
-RepositoryOwner "PrimeBuild" `
495+
-PackageIdentifier "PrimeBuild.ThreadPilot" `
496+
-GithubToken "$env:WINGET_GITHUB_TOKEN"
497+
498+
publish-chocolatey:
499+
runs-on: windows-latest
500+
needs:
501+
- build
502+
- release
503+
if: needs.release.result == 'success'
504+
505+
steps:
506+
- name: Check chocolatey publish secret
507+
id: gate
508+
shell: pwsh
509+
env:
510+
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
511+
run: |
512+
if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY))
513+
{
514+
Write-Host "Chocolatey API key missing. Skipping publish-chocolatey job."
515+
"enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
516+
exit 0
517+
}
518+
519+
"enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
520+
521+
- name: Checkout
522+
if: steps.gate.outputs.enabled == 'true'
523+
uses: actions/checkout@v4
524+
525+
- name: Download installer artifact
526+
if: steps.gate.outputs.enabled == 'true'
527+
uses: actions/download-artifact@v4
528+
with:
529+
name: release-installer
530+
path: release-assets/installer
531+
532+
- name: Publish chocolatey package
533+
if: steps.gate.outputs.enabled == 'true'
534+
shell: pwsh
535+
env:
536+
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
537+
run: |
538+
$ErrorActionPreference = "Stop"
539+
$installer = Get-ChildItem "release-assets/installer/*.exe" -File | Select-Object -First 1
540+
if (-not $installer)
541+
{
542+
throw "Installer executable not found for Chocolatey publish."
543+
}
544+
545+
./build/publish-chocolatey.ps1 `
546+
-Version "${{ needs.build.outputs.version }}" `
547+
-Tag "${{ needs.build.outputs.tag }}" `
548+
-InstallerPath $installer.FullName `
549+
-ApiKey "$env:CHOCOLATEY_API_KEY"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Notes:
126126

127127
- Winget visibility depends on microsoft/winget-pkgs publication and client source refresh.
128128
- Chocolatey visibility depends on moderation and verification approval state.
129+
- The release workflow can auto-submit winget and Chocolatey publication jobs when repository secrets are configured.
129130
- ThreadPilot uses an administrator-required manifest (`requireAdministrator`) and requests elevation at startup.
130131
- If UAC elevation is declined at startup, the application exits and does not continue in limited mode.
131132
- In `Power Plans > Custom Power Plans`, use `Add .pow File` to add new custom plans directly from the app.

build/publish-chocolatey.ps1

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string]$Version,
4+
5+
[Parameter(Mandatory = $true)]
6+
[string]$Tag,
7+
8+
[Parameter(Mandatory = $true)]
9+
[string]$InstallerPath,
10+
11+
[Parameter(Mandatory = $true)]
12+
[string]$ApiKey
13+
)
14+
15+
Set-StrictMode -Version Latest
16+
$ErrorActionPreference = 'Stop'
17+
18+
function Assert-CommandAvailable {
19+
param([string]$CommandName)
20+
21+
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) {
22+
throw "Required command '$CommandName' was not found in PATH."
23+
}
24+
}
25+
26+
Assert-CommandAvailable -CommandName 'choco'
27+
28+
if (-not (Test-Path -LiteralPath $InstallerPath)) {
29+
throw "Installer executable not found: $InstallerPath"
30+
}
31+
32+
$scriptRoot = Split-Path -Parent $PSCommandPath
33+
$projectRoot = Split-Path -Parent $scriptRoot
34+
$sourceChocoRoot = Join-Path $projectRoot 'chocolatey'
35+
36+
if (-not (Test-Path -LiteralPath $sourceChocoRoot)) {
37+
throw "Chocolatey source folder not found: $sourceChocoRoot"
38+
}
39+
40+
$workRoot = Join-Path $env:RUNNER_TEMP 'threadpilot-choco-publish'
41+
if (Test-Path -LiteralPath $workRoot) {
42+
Remove-Item -LiteralPath $workRoot -Recurse -Force
43+
}
44+
45+
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
46+
Copy-Item -Path (Join-Path $sourceChocoRoot '*') -Destination $workRoot -Recurse -Force
47+
48+
$nuspecPath = Join-Path $workRoot 'threadpilot.nuspec'
49+
$installScriptPath = Join-Path $workRoot 'tools\chocolateyInstall.ps1'
50+
51+
if (-not (Test-Path -LiteralPath $nuspecPath)) {
52+
throw "Nuspec file not found: $nuspecPath"
53+
}
54+
55+
if (-not (Test-Path -LiteralPath $installScriptPath)) {
56+
throw "chocolateyInstall.ps1 not found: $installScriptPath"
57+
}
58+
59+
$hash = (Get-FileHash -LiteralPath $InstallerPath -Algorithm SHA256).Hash.ToLowerInvariant()
60+
$installerFileName = Split-Path -Leaf $InstallerPath
61+
$installerUrl = "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/$Tag/$installerFileName"
62+
$releaseNotesUrl = "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/$Tag"
63+
64+
[xml]$nuspec = Get-Content -LiteralPath $nuspecPath
65+
$nuspec.package.metadata.version = $Version
66+
$nuspec.package.metadata.releaseNotes = $releaseNotesUrl
67+
$nuspec.Save($nuspecPath)
68+
69+
$installScript = Get-Content -LiteralPath $installScriptPath -Raw
70+
$installScript = [regex]::Replace($installScript, "\$url64\s*=\s*'.*?'", "`$url64 = '$installerUrl'")
71+
$installScript = [regex]::Replace($installScript, "\$checksum64\s*=\s*'.*?'", "`$checksum64 = '$hash'")
72+
Set-Content -LiteralPath $installScriptPath -Value $installScript -Encoding Ascii
73+
74+
Push-Location -LiteralPath $workRoot
75+
try {
76+
& choco pack threadpilot.nuspec --outputdirectory .
77+
if ($LASTEXITCODE -ne 0) {
78+
throw 'choco pack failed.'
79+
}
80+
81+
$nupkg = Get-ChildItem -LiteralPath $workRoot -Filter 'threadpilot.*.nupkg' -File | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
82+
if (-not $nupkg) {
83+
throw 'Chocolatey package was not created.'
84+
}
85+
86+
$pushOutput = (& choco push $nupkg.FullName --source 'https://push.chocolatey.org/' --api-key $ApiKey --timeout 2700 2>&1 | Out-String)
87+
if ($LASTEXITCODE -ne 0) {
88+
if ($pushOutput -match 'already exists') {
89+
Write-Host 'Chocolatey package already exists. Treating as successful idempotent publish.'
90+
exit 0
91+
}
92+
93+
throw "choco push failed. Output: $pushOutput"
94+
}
95+
96+
Write-Host 'Chocolatey package published successfully.'
97+
}
98+
finally {
99+
Pop-Location
100+
}

build/submit-winget-pr.ps1

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string]$Version,
4+
5+
[Parameter(Mandatory = $true)]
6+
[string]$ManifestSourcePath,
7+
8+
[Parameter(Mandatory = $true)]
9+
[string]$ForkOwner,
10+
11+
[Parameter(Mandatory = $true)]
12+
[string]$RepositoryOwner,
13+
14+
[Parameter(Mandatory = $true)]
15+
[string]$PackageIdentifier,
16+
17+
[Parameter(Mandatory = $true)]
18+
[string]$GithubToken
19+
)
20+
21+
Set-StrictMode -Version Latest
22+
$ErrorActionPreference = 'Stop'
23+
24+
function Assert-CommandAvailable {
25+
param([string]$CommandName)
26+
27+
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) {
28+
throw "Required command '$CommandName' was not found in PATH."
29+
}
30+
}
31+
32+
Assert-CommandAvailable -CommandName 'git'
33+
Assert-CommandAvailable -CommandName 'gh'
34+
35+
if (-not (Test-Path -LiteralPath $ManifestSourcePath)) {
36+
throw "Manifest source path not found: $ManifestSourcePath"
37+
}
38+
39+
$manifestFiles = Get-ChildItem -LiteralPath $ManifestSourcePath -Recurse -File -Filter '*.yaml'
40+
if (-not $manifestFiles -or $manifestFiles.Count -eq 0) {
41+
throw "No winget manifest files found under $ManifestSourcePath"
42+
}
43+
44+
$scriptRoot = Split-Path -Parent $PSCommandPath
45+
$projectRoot = Split-Path -Parent $scriptRoot
46+
$workDir = Join-Path $env:RUNNER_TEMP 'winget-pkgs-work'
47+
48+
if (Test-Path -LiteralPath $workDir) {
49+
Remove-Item -LiteralPath $workDir -Recurse -Force
50+
}
51+
52+
$env:GH_TOKEN = $GithubToken
53+
54+
Write-Host 'Cloning winget-pkgs fork...'
55+
& git clone "https://x-access-token:$GithubToken@github.com/$ForkOwner/winget-pkgs.git" $workDir
56+
if ($LASTEXITCODE -ne 0) {
57+
throw 'Failed to clone winget-pkgs fork.'
58+
}
59+
60+
Push-Location -LiteralPath $workDir
61+
try {
62+
& git config user.name 'ThreadPilot Release Bot'
63+
& git config user.email 'threadpilot-release-bot@users.noreply.github.com'
64+
65+
$branch = "threadpilot-$Version"
66+
& git checkout -B $branch
67+
68+
$packageParts = $PackageIdentifier.Split('.')
69+
if ($packageParts.Count -lt 2) {
70+
throw "PackageIdentifier must contain at least one dot: $PackageIdentifier"
71+
}
72+
73+
$packagePublisher = $packageParts[0]
74+
$packageName = $packageParts[1]
75+
76+
if ($RepositoryOwner -ne $packagePublisher) {
77+
throw "RepositoryOwner '$RepositoryOwner' does not match package publisher '$packagePublisher'."
78+
}
79+
80+
$firstLetter = $packagePublisher.Substring(0, 1).ToLowerInvariant()
81+
$targetDir = Join-Path $workDir (Join-Path 'manifests' (Join-Path $firstLetter (Join-Path $packagePublisher (Join-Path $packageName $Version))))
82+
83+
if (Test-Path -LiteralPath $targetDir) {
84+
Remove-Item -LiteralPath $targetDir -Recurse -Force
85+
}
86+
87+
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
88+
89+
foreach ($file in $manifestFiles) {
90+
Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $targetDir $file.Name) -Force
91+
}
92+
93+
& git add $targetDir
94+
& git status --short
95+
96+
$hasChanges = (& git diff --cached --name-only | Out-String).Trim()
97+
if ([string]::IsNullOrWhiteSpace($hasChanges)) {
98+
Write-Host "No manifest changes detected for $Version. Nothing to submit."
99+
exit 0
100+
}
101+
102+
& git commit -m "Add $PackageIdentifier version $Version"
103+
& git push -u origin $branch --force
104+
105+
$existingPr = (& gh pr list --repo microsoft/winget-pkgs --state open --head "$ForkOwner`:$branch" --json number | Out-String).Trim()
106+
if (-not [string]::IsNullOrWhiteSpace($existingPr) -and $existingPr -ne '[]') {
107+
Write-Host "Winget PR already exists for branch $branch."
108+
exit 0
109+
}
110+
111+
$title = "New version: $PackageIdentifier version $Version"
112+
$body = @"
113+
Automated submission from ThreadPilot release workflow.
114+
115+
- Package: $PackageIdentifier
116+
- Version: $Version
117+
"@
118+
119+
& gh pr create --repo microsoft/winget-pkgs --base master --head "$ForkOwner`:$branch" --title $title --body $body
120+
if ($LASTEXITCODE -ne 0) {
121+
throw 'Failed to create winget-pkgs PR.'
122+
}
123+
124+
Write-Host "Winget PR submitted successfully for $PackageIdentifier $Version"
125+
}
126+
finally {
127+
Pop-Location
128+
}

docs/release/PACKAGING.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,16 @@ Channel behavior:
146146
Current workflow scope:
147147

148148
- `.github/workflows/release.yml` builds release artifacts and uploads winget manifests as artifacts.
149-
- It does not automatically submit a PR to microsoft/winget-pkgs.
150-
- It does not automatically publish to Chocolatey community moderation.
149+
- It automatically submits a PR to `microsoft/winget-pkgs` when release succeeds and winget secrets are configured.
150+
- It automatically publishes to Chocolatey community feed when release succeeds and Chocolatey API key is configured.
151+
152+
Required repository secrets for full channel automation:
153+
154+
- `WINGET_GITHUB_TOKEN`: PAT with permission to push to your `winget-pkgs` fork and create PRs.
155+
- `WINGET_FORK_OWNER`: GitHub username/org that owns your `winget-pkgs` fork.
156+
- `CHOCOLATEY_API_KEY`: API key for `https://push.chocolatey.org/`.
157+
158+
If secrets are missing, publish jobs are skipped and GitHub release remains successful.
151159

152160
Optional automation for publishing the GitHub release after artifacts are ready:
153161

0 commit comments

Comments
 (0)