Skip to content

Commit 0796652

Browse files
Add environment-gated release publishing and signing (#4)
## Summary - Add `publish-test` / `publish-prod` environment selection to the Release workflow via `github-env`. - Gate package/signing and publishing jobs on the selected GitHub environment. - Sign Windows Rust release artifacts before upload. - Sign C# NuGet and PowerShell module binaries, including bundled module dependencies, before packaging/upload. - Switch NuGet publishing to `NuGet/login@v1` OIDC and use `PSGALLERY_NUGET_API_KEY` for PowerShell Gallery publishing. ## Validation - Triggered a dry run with test signing: https://github.com/Devolutions/pinget/actions/runs/25006418987 - Downloaded and expanded artifacts from the dry run. - Verified all 19 `.exe` / `.dll` files in the uploaded artifacts report Authenticode `Valid`. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c117097 commit 0796652

1 file changed

Lines changed: 240 additions & 18 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 240 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,21 @@ on:
1515
required: false
1616
default: true
1717
type: boolean
18+
github-env:
19+
description: GitHub environment selection.
20+
required: true
21+
type: choice
22+
options:
23+
- auto
24+
- test
25+
- prod
26+
default: auto
1827
publish-nuget:
1928
description: Publish packages to NuGet.org during a manual release.
2029
required: false
2130
default: false
2231
type: boolean
23-
publish-powershell-gallery:
32+
publish-psgallery:
2433
description: Publish Devolutions.Pinget.Client to PowerShell Gallery during a manual release.
2534
required: false
2635
default: false
@@ -41,8 +50,9 @@ jobs:
4150
version: ${{ steps.info.outputs.version }}
4251
tag-name: ${{ steps.info.outputs.tag-name }}
4352
dry-run: ${{ steps.info.outputs.dry-run }}
53+
package-env: ${{ steps.info.outputs.package-env }}
4454
publish-nuget: ${{ steps.info.outputs.publish-nuget }}
45-
publish-powershell-gallery: ${{ steps.info.outputs.publish-powershell-gallery }}
55+
publish-psgallery: ${{ steps.info.outputs.publish-psgallery }}
4656

4757
steps:
4858
- name: Checkout
@@ -58,17 +68,49 @@ jobs:
5868
$DryRun = $false
5969
$PublishNuget = $true
6070
$PublishPowerShellGallery = $true
71+
$RequestedEnvironment = 'auto'
6172
6273
if ($IsWorkflowDispatch) {
6374
try { $DryRun = [System.Boolean]::Parse('${{ inputs['dry-run'] }}') } catch { $DryRun = $true }
6475
try { $PublishNuget = [System.Boolean]::Parse('${{ inputs['publish-nuget'] }}') } catch { $PublishNuget = $false }
65-
try { $PublishPowerShellGallery = [System.Boolean]::Parse('${{ inputs['publish-powershell-gallery'] }}') } catch { $PublishPowerShellGallery = $false }
76+
try { $PublishPowerShellGallery = [System.Boolean]::Parse('${{ inputs['publish-psgallery'] }}') } catch { $PublishPowerShellGallery = $false }
77+
78+
$RequestedEnvironment = '${{ inputs['github-env'] }}'.Trim().ToLowerInvariant()
79+
if ([string]::IsNullOrWhiteSpace($RequestedEnvironment)) {
80+
$RequestedEnvironment = 'auto'
81+
}
82+
}
83+
84+
switch ($RequestedEnvironment) {
85+
'auto' {
86+
$PackageEnv = if ($IsMasterBranch) {
87+
'publish-prod'
88+
} else {
89+
'publish-test'
90+
}
91+
}
92+
'test' {
93+
$PackageEnv = 'publish-test'
94+
}
95+
'prod' {
96+
$PackageEnv = 'publish-prod'
97+
}
98+
default {
99+
throw "Unsupported github-env value: $RequestedEnvironment (expected auto, test, or prod)"
100+
}
101+
}
102+
103+
if ($RequestedEnvironment -eq 'test' -and ($IsMasterBranch -or (-not $DryRun -and ($PublishNuget -or $PublishPowerShellGallery)))) {
104+
$PackageEnv = 'publish-prod'
105+
Write-Host '::notice::github-env=test was promoted to publish-prod'
66106
}
67107
68108
if (-not $IsMasterBranch) {
69-
$DryRun = $true
70-
$PublishNuget = $false
71-
$PublishPowerShellGallery = $false
109+
if ($PackageEnv -ne 'publish-prod') {
110+
$DryRun = $true
111+
$PublishNuget = $false
112+
$PublishPowerShellGallery = $false
113+
}
72114
}
73115
74116
if ($DryRun) {
@@ -106,19 +148,23 @@ jobs:
106148
"version=$Version" >> $Env:GITHUB_OUTPUT
107149
"tag-name=$TagName" >> $Env:GITHUB_OUTPUT
108150
"dry-run=$($DryRun.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
151+
"package-env=$PackageEnv" >> $Env:GITHUB_OUTPUT
109152
"publish-nuget=$($PublishNuget.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
110-
"publish-powershell-gallery=$($PublishPowerShellGallery.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
153+
"publish-psgallery=$($PublishPowerShellGallery.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
111154
112155
Write-Host "::notice::Version: $Version"
113156
Write-Host "::notice::Tag: $TagName"
114157
Write-Host "::notice::DryRun: $DryRun"
158+
Write-Host "::notice::Environment: $PackageEnv"
159+
Write-Host "::notice::GitHubEnvMode: $RequestedEnvironment"
115160
Write-Host "::notice::PublishNuget: $PublishNuget"
116161
Write-Host "::notice::PublishPowerShellGallery: $PublishPowerShellGallery"
117162
118163
rust-artifacts:
119164
name: Rust Artifacts (${{ matrix.name }})
120165
runs-on: ${{ matrix.os }}
121166
needs: preflight
167+
environment: ${{ needs.preflight.outputs.package-env }}
122168
strategy:
123169
fail-fast: false
124170
matrix:
@@ -184,6 +230,72 @@ jobs:
184230
run: |
185231
cargo build --release --package pinget-cli --bin pinget --manifest-path rust/Cargo.toml --target "${{ matrix.target }}"
186232
233+
- name: Install code signing tools
234+
if: contains(matrix.target, 'windows-msvc')
235+
shell: pwsh
236+
run: |
237+
dotnet tool install --global AzureSignTool
238+
239+
$TestCertsUrl = "https://raw.githubusercontent.com/Devolutions/devolutions-authenticode/master/data/certs"
240+
Invoke-WebRequest -Uri "$TestCertsUrl/authenticode-test-ca.crt" -OutFile ".\authenticode-test-ca.crt"
241+
Import-Certificate -FilePath ".\authenticode-test-ca.crt" -CertStoreLocation "cert:\LocalMachine\Root"
242+
Remove-Item ".\authenticode-test-ca.crt" -ErrorAction SilentlyContinue | Out-Null
243+
244+
- name: Code sign Rust binary
245+
if: contains(matrix.target, 'windows-msvc')
246+
shell: pwsh
247+
env:
248+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
249+
CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}
250+
CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }}
251+
CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }}
252+
CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }}
253+
CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}
254+
TARGET: ${{ matrix.target }}
255+
PINGET_BIN_NAME: ${{ matrix.binary_name }}
256+
run: |
257+
$PackageEnv = '${{ needs.preflight.outputs.package-env }}'
258+
$DryRun = [System.Boolean]::Parse('${{ needs.preflight.outputs.dry-run }}')
259+
$RequiredVariables = @(
260+
'AZURE_TENANT_ID',
261+
'CODE_SIGNING_CERTIFICATE_NAME',
262+
'CODE_SIGNING_CLIENT_ID',
263+
'CODE_SIGNING_CLIENT_SECRET',
264+
'CODE_SIGNING_KEYVAULT_URL',
265+
'CODE_SIGNING_TIMESTAMP_SERVER'
266+
)
267+
$MissingVariables = @($RequiredVariables | Where-Object {
268+
[string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_))
269+
})
270+
271+
if ($MissingVariables.Count -gt 0) {
272+
$Message = "Code signing configuration missing: $($MissingVariables -join ', ')"
273+
if ($PackageEnv -eq 'publish-prod' -and -not $DryRun) {
274+
throw $Message
275+
}
276+
277+
Write-Host "$Message; skipping code signing."
278+
return
279+
}
280+
281+
$BinaryPath = [System.IO.Path]::Combine('rust', 'target', $env:TARGET, 'release', $env:PINGET_BIN_NAME)
282+
if (-not (Test-Path -Path $BinaryPath)) {
283+
throw "Binary not found: $BinaryPath"
284+
}
285+
286+
AzureSignTool sign `
287+
-kvt $env:AZURE_TENANT_ID `
288+
-kvu $env:CODE_SIGNING_KEYVAULT_URL `
289+
-kvi $env:CODE_SIGNING_CLIENT_ID `
290+
-kvs $env:CODE_SIGNING_CLIENT_SECRET `
291+
-kvc $env:CODE_SIGNING_CERTIFICATE_NAME `
292+
-tr $env:CODE_SIGNING_TIMESTAMP_SERVER `
293+
-v `
294+
$BinaryPath
295+
if ($LASTEXITCODE -ne 0) {
296+
throw "Code signing failed for $BinaryPath."
297+
}
298+
187299
- name: Package artifacts
188300
shell: pwsh
189301
env:
@@ -232,6 +344,7 @@ jobs:
232344
name: C# Packages
233345
runs-on: windows-latest
234346
needs: preflight
347+
environment: ${{ needs.preflight.outputs.package-env }}
235348

236349
steps:
237350
- name: Checkout
@@ -261,6 +374,87 @@ jobs:
261374
shell: pwsh
262375
run: pwsh -NoLogo -NoProfile -File (Resolve-Path 'dotnet/tests/RunTests.ps1')
263376

377+
- name: Install code signing tools
378+
shell: pwsh
379+
run: |
380+
dotnet tool install --global AzureSignTool
381+
382+
$TestCertsUrl = "https://raw.githubusercontent.com/Devolutions/devolutions-authenticode/master/data/certs"
383+
Invoke-WebRequest -Uri "$TestCertsUrl/authenticode-test-ca.crt" -OutFile ".\authenticode-test-ca.crt"
384+
Import-Certificate -FilePath ".\authenticode-test-ca.crt" -CertStoreLocation "cert:\LocalMachine\Root"
385+
Remove-Item ".\authenticode-test-ca.crt" -ErrorAction SilentlyContinue | Out-Null
386+
387+
- name: Code sign C# binaries
388+
shell: pwsh
389+
env:
390+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
391+
CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}
392+
CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }}
393+
CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }}
394+
CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }}
395+
CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}
396+
run: |
397+
$PackageEnv = '${{ needs.preflight.outputs.package-env }}'
398+
$DryRun = [System.Boolean]::Parse('${{ needs.preflight.outputs.dry-run }}')
399+
$RequiredVariables = @(
400+
'AZURE_TENANT_ID',
401+
'CODE_SIGNING_CERTIFICATE_NAME',
402+
'CODE_SIGNING_CLIENT_ID',
403+
'CODE_SIGNING_CLIENT_SECRET',
404+
'CODE_SIGNING_KEYVAULT_URL',
405+
'CODE_SIGNING_TIMESTAMP_SERVER'
406+
)
407+
$MissingVariables = @($RequiredVariables | Where-Object {
408+
[string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_))
409+
})
410+
411+
if ($MissingVariables.Count -gt 0) {
412+
$Message = "Code signing configuration missing: $($MissingVariables -join ', ')"
413+
if ($PackageEnv -eq 'publish-prod' -and -not $DryRun) {
414+
throw $Message
415+
}
416+
417+
Write-Host "$Message; skipping code signing."
418+
return
419+
}
420+
421+
$SignParams = @(
422+
'sign',
423+
'-kvt', $env:AZURE_TENANT_ID,
424+
'-kvu', $env:CODE_SIGNING_KEYVAULT_URL,
425+
'-kvi', $env:CODE_SIGNING_CLIENT_ID,
426+
'-kvs', $env:CODE_SIGNING_CLIENT_SECRET,
427+
'-kvc', $env:CODE_SIGNING_CERTIFICATE_NAME,
428+
'-tr', $env:CODE_SIGNING_TIMESTAMP_SERVER,
429+
'-v'
430+
)
431+
432+
$BinaryRoots = @(
433+
'dotnet/src/Devolutions.Pinget.Core/bin/Release',
434+
'dotnet/src/Devolutions.Pinget.PowerShell.Engine/bin/Release',
435+
'dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/bin/Release'
436+
)
437+
$Binaries = @(Get-ChildItem -Path $BinaryRoots -Recurse -File -Include '*.dll','*.exe' | Where-Object {
438+
$_.FullName -like '*\bin\Release\*' -and $_.FullName -notmatch '\\ref\\'
439+
})
440+
441+
if ($Binaries.Count -eq 0) {
442+
throw 'No C# binaries were found for code signing.'
443+
}
444+
445+
foreach ($Binary in $Binaries) {
446+
$Signature = Get-AuthenticodeSignature -FilePath $Binary.FullName
447+
if ($Signature.Status -eq 'Valid') {
448+
Write-Host "Code signing skipped for already-signed binary: $($Binary.FullName)"
449+
continue
450+
}
451+
452+
AzureSignTool @SignParams $Binary.FullName
453+
if ($LASTEXITCODE -ne 0) {
454+
throw "Code signing failed for $($Binary.FullName)."
455+
}
456+
}
457+
264458
- name: Pack NuGet packages
265459
shell: pwsh
266460
env:
@@ -352,8 +546,10 @@ jobs:
352546
publish-nuget:
353547
name: Publish NuGet
354548
runs-on: ubuntu-latest
355-
env:
356-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
549+
environment: ${{ needs.preflight.outputs.package-env }}
550+
permissions:
551+
contents: read
552+
id-token: write
357553
needs:
358554
- preflight
359555
- csharp-packages
@@ -367,30 +563,53 @@ jobs:
367563
with:
368564
dotnet-version: 10.0.x
369565

566+
- name: NuGet login (OIDC)
567+
id: nuget-login
568+
uses: NuGet/login@v1
569+
with:
570+
user: ${{ secrets.NUGET_BOT_USERNAME }}
571+
370572
- name: Download package artifacts
371573
uses: actions/download-artifact@v8
372574
with:
373575
name: csharp-nuget
374576
path: dist/nuget
375577

376578
- name: Push packages to NuGet.org
377-
if: env.NUGET_API_KEY != ''
378-
shell: bash
579+
shell: pwsh
379580
run: |
380-
dotnet nuget push dist/nuget/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
381-
dotnet nuget push dist/nuget/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
581+
$Packages = @(Get-ChildItem -Path dist/nuget -Filter '*.nupkg')
582+
if ($Packages.Count -eq 0) {
583+
throw 'No NuGet packages were downloaded.'
584+
}
382585
383-
publish-powershell-gallery:
586+
foreach ($Package in $Packages) {
587+
dotnet nuget push $Package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate
588+
if ($LASTEXITCODE -ne 0) {
589+
throw "NuGet package publishing failed for $($Package.FullName)."
590+
}
591+
}
592+
593+
$SymbolPackages = @(Get-ChildItem -Path dist/nuget -Filter '*.snupkg')
594+
foreach ($SymbolPackage in $SymbolPackages) {
595+
dotnet nuget push $SymbolPackage.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate
596+
if ($LASTEXITCODE -ne 0) {
597+
throw "NuGet symbol package publishing failed for $($SymbolPackage.FullName)."
598+
}
599+
}
600+
601+
publish-psgallery:
384602
name: Publish PowerShell Gallery
385603
runs-on: ubuntu-latest
604+
environment: ${{ needs.preflight.outputs.package-env }}
386605
env:
387-
PS_GALLERY_API_KEY: ${{ secrets.PS_GALLERY_API_KEY }}
606+
PSGALLERY_NUGET_API_KEY: ${{ secrets.PSGALLERY_NUGET_API_KEY }}
388607
needs:
389608
- preflight
390609
- csharp-packages
391610
- create-tag
392611
- github-release
393-
if: ${{ fromJSON(needs.preflight.outputs.dry-run) == false && fromJSON(needs.preflight.outputs.publish-powershell-gallery) == true }}
612+
if: ${{ fromJSON(needs.preflight.outputs.dry-run) == false && fromJSON(needs.preflight.outputs.publish-psgallery) == true }}
394613

395614
steps:
396615
- name: Download PowerShell module artifact
@@ -400,9 +619,12 @@ jobs:
400619
path: dist/powershell-module
401620

402621
- name: Publish Devolutions.Pinget.Client to PowerShell Gallery
403-
if: env.PS_GALLERY_API_KEY != ''
404622
shell: pwsh
405623
run: |
624+
if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_NUGET_API_KEY)) {
625+
throw 'PSGALLERY_NUGET_API_KEY is not configured.'
626+
}
627+
406628
$archive = Get-ChildItem -Path dist/powershell-module -Filter 'Devolutions.Pinget.Client.*.zip' | Select-Object -First 1
407629
if ($null -eq $archive) {
408630
throw 'PowerShell module archive was not downloaded.'
@@ -413,4 +635,4 @@ jobs:
413635
$modulePath = (Resolve-Path (Join-Path $expandedRoot 'Devolutions.Pinget.Client')).Path
414636
415637
Test-ModuleManifest -Path (Join-Path $modulePath 'Devolutions.Pinget.Client.psd1') | Out-Null
416-
Publish-Module -Path $modulePath -NuGetApiKey $env:PS_GALLERY_API_KEY -Verbose
638+
Publish-Module -Path $modulePath -NuGetApiKey $env:PSGALLERY_NUGET_API_KEY -Verbose

0 commit comments

Comments
 (0)