Skip to content

Commit 69cc510

Browse files
committed
Harden services and release coverage pipeline
1 parent 8901150 commit 69cc510

38 files changed

Lines changed: 2164 additions & 588 deletions

.github/workflows/ci-devsecops.yml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,42 @@ jobs:
3737
run: dotnet build "ThreadPilot_1.sln" --configuration Release --no-restore
3838

3939
- name: Run tests
40-
run: dotnet test "ThreadPilot_1.sln" --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory TestResults
40+
run: dotnet test "Tests/ThreadPilot.Core.Tests/ThreadPilot.Core.Tests.csproj" --configuration Release --no-restore --verbosity normal --collect:"XPlat Code Coverage" --settings "Tests/ThreadPilot.Core.Tests/coverlet.runsettings" --results-directory TestResults
41+
42+
- name: Enforce coverage gate
43+
shell: pwsh
44+
run: |
45+
$ErrorActionPreference = "Stop"
46+
$coveragePath = Get-ChildItem -Path "TestResults" -Recurse -Filter "coverage.cobertura.xml" |
47+
Sort-Object LastWriteTimeUtc -Descending |
48+
Select-Object -First 1 -ExpandProperty FullName
49+
50+
if ([string]::IsNullOrWhiteSpace($coveragePath))
51+
{
52+
throw "Coverage file not found under TestResults."
53+
}
54+
55+
[xml]$coverage = Get-Content $coveragePath
56+
$lineRate = [double]$coverage.coverage.'line-rate' * 100
57+
$branchRate = [double]$coverage.coverage.'branch-rate' * 100
58+
# Temporary floor until the service-hardening coverage tasks land.
59+
$minimumLineRate = 1.5
60+
61+
$summary = @(
62+
"## Coverage Gate",
63+
"- file: $coveragePath",
64+
"- line coverage: $([math]::Round($lineRate, 2))%",
65+
"- branch coverage: $([math]::Round($branchRate, 2))%",
66+
"- minimum line coverage: $minimumLineRate%"
67+
)
68+
69+
$summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
70+
$summary | ForEach-Object { Write-Host $_ }
71+
72+
if ($lineRate -lt $minimumLineRate)
73+
{
74+
throw "Coverage gate failed. Expected >= $minimumLineRate, actual: $([math]::Round($lineRate, 2))"
75+
}
4176
4277
- name: Upload coverage
4378
uses: codecov/codecov-action@v4

.github/workflows/release.yml

Lines changed: 222 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ on:
55
tags:
66
- "v*.*.*"
77
workflow_dispatch:
8+
inputs:
9+
dry_run_publish:
10+
description: "Build and validate release without external channel publication"
11+
required: false
12+
default: "false"
813

914
permissions:
1015
contents: write
@@ -56,7 +61,7 @@ jobs:
5661
run: dotnet build "ThreadPilot_1.sln" --configuration Release --no-restore
5762

5863
- name: Test
59-
run: dotnet test "ThreadPilot_1.sln" --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory TestResults
64+
run: dotnet test "Tests/ThreadPilot.Core.Tests/ThreadPilot.Core.Tests.csproj" --configuration Release --no-restore --verbosity normal --collect:"XPlat Code Coverage" --settings "Tests/ThreadPilot.Core.Tests/coverlet.runsettings" --results-directory TestResults
6065

6166
- name: Publish self-contained single-file
6267
run: dotnet publish "ThreadPilot.csproj" --configuration Release -p:PublishProfile=WinX64-SingleFile
@@ -216,7 +221,7 @@ jobs:
216221
"$($hash.Hash) $($_.Name)" | Out-File -FilePath $hashFile -Append -Encoding utf8
217222
}
218223
219-
- name: Update winget installer manifest
224+
- name: Generate winget manifests
220225
shell: pwsh
221226
run: |
222227
$ErrorActionPreference = "Stop"
@@ -230,16 +235,13 @@ jobs:
230235
231236
$sha = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToUpperInvariant()
232237
$url = "https://github.com/${{ github.repository }}/releases/download/$tag/$($installer.Name)"
233-
$manifestPath = "winget/manifests/p/PrimeBuild/ThreadPilot/$version/PrimeBuild.ThreadPilot.installer.yaml"
234238
235-
if (-not (Test-Path $manifestPath))
236-
{
237-
throw "Winget installer manifest not found at $manifestPath"
238-
}
239-
240-
$content = Get-Content $manifestPath -Raw
241-
$content = $content.Replace("{{INSTALLER_URL}}", $url).Replace("{{INSTALLER_SHA256}}", $sha)
242-
Set-Content -Path $manifestPath -Value $content -Encoding utf8
239+
./build/generate-winget-manifests.ps1 `
240+
-Version $version `
241+
-Tag $tag `
242+
-InstallerUrl $url `
243+
-InstallerSha256 $sha `
244+
-OutputRoot "winget-manifests"
243245
244246
- name: Generate SBOM
245247
shell: pwsh
@@ -291,7 +293,7 @@ jobs:
291293
uses: actions/upload-artifact@v4
292294
with:
293295
name: winget-manifests
294-
path: winget/manifests/p/PrimeBuild/ThreadPilot/${{ steps.version.outputs.version }}
296+
path: winget-manifests
295297

296298
- name: Upload SBOM
297299
uses: actions/upload-artifact@v4
@@ -452,20 +454,59 @@ jobs:
452454
if: needs.release.result == 'success'
453455

454456
steps:
455-
- name: Check winget publish secrets
457+
- name: Resolve winget publish policy
458+
id: policy
459+
shell: pwsh
460+
run: |
461+
$ErrorActionPreference = "Stop"
462+
$dryRun = "${{ github.event_name }}" -eq "workflow_dispatch" -and "${{ github.event.inputs.dry_run_publish }}" -eq "true"
463+
"dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
464+
465+
- name: Check winget publish policy
456466
id: gate
457467
shell: pwsh
458468
env:
459469
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
460470
WINGET_FORK_OWNER: ${{ secrets.WINGET_FORK_OWNER }}
461471
run: |
462-
if ([string]::IsNullOrWhiteSpace($env:WINGET_GITHUB_TOKEN) -or [string]::IsNullOrWhiteSpace($env:WINGET_FORK_OWNER))
472+
$ErrorActionPreference = "Stop"
473+
$logsDir = "release-channel-logs"
474+
New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
475+
$logPath = Join-Path $logsDir "winget-submit-log.txt"
476+
$dryRun = "${{ steps.policy.outputs.dry_run }}" -eq "true"
477+
478+
if ($dryRun)
463479
{
464-
Write-Host "Winget publish secrets missing. Skipping publish-winget job."
480+
$summary = @(
481+
"## winget publish",
482+
"- attempted: false",
483+
"- version: ${{ needs.build.outputs.version }}",
484+
"- result: dry-run",
485+
"- reason: workflow_dispatch requested dry_run_publish=true"
486+
)
487+
488+
$summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
489+
"Dry run requested. Winget submission intentionally skipped." | Set-Content -Path $logPath -Encoding utf8
465490
"enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
466491
exit 0
467492
}
468493
494+
if ([string]::IsNullOrWhiteSpace($env:WINGET_GITHUB_TOKEN) -or [string]::IsNullOrWhiteSpace($env:WINGET_FORK_OWNER))
495+
{
496+
$message = "Winget publish secrets are missing. Refusing to report a green public release without a submission attempt."
497+
$summary = @(
498+
"## winget publish",
499+
"- attempted: false",
500+
"- version: ${{ needs.build.outputs.version }}",
501+
"- result: failed-policy",
502+
"- reason: missing WINGET_GITHUB_TOKEN and/or WINGET_FORK_OWNER"
503+
)
504+
505+
$summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
506+
$message | Set-Content -Path $logPath -Encoding utf8
507+
throw $message
508+
}
509+
469510
"enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
470511
471512
- name: Checkout
@@ -487,13 +528,50 @@ jobs:
487528
WINGET_FORK_OWNER: ${{ secrets.WINGET_FORK_OWNER }}
488529
run: |
489530
$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"
531+
$logsDir = "release-channel-logs"
532+
New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
533+
$logPath = Join-Path $logsDir "winget-submit-log.txt"
534+
535+
try
536+
{
537+
& ./build/submit-winget-pr.ps1 `
538+
-Version "${{ needs.build.outputs.version }}" `
539+
-ManifestSourcePath "winget-manifests" `
540+
-ForkOwner "$env:WINGET_FORK_OWNER" `
541+
-RepositoryOwner "PrimeBuild" `
542+
-PackageIdentifier "PrimeBuild.ThreadPilot" `
543+
-GithubToken "$env:WINGET_GITHUB_TOKEN" *>&1 | Tee-Object -FilePath $logPath
544+
545+
if ($LASTEXITCODE -ne 0)
546+
{
547+
throw "submit-winget-pr.ps1 exited with code $LASTEXITCODE"
548+
}
549+
550+
@(
551+
"## winget publish",
552+
"- attempted: true",
553+
"- version: ${{ needs.build.outputs.version }}",
554+
"- result: submitted"
555+
) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
556+
}
557+
catch
558+
{
559+
@(
560+
"## winget publish",
561+
"- attempted: true",
562+
"- version: ${{ needs.build.outputs.version }}",
563+
"- result: failed"
564+
) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
565+
throw
566+
}
567+
568+
- name: Upload winget publish logs
569+
if: always()
570+
uses: actions/upload-artifact@v4
571+
with:
572+
name: winget-publish-logs
573+
path: release-channel-logs/winget-submit-log.txt
574+
if-no-files-found: error
497575

498576
publish-chocolatey:
499577
runs-on: windows-latest
@@ -503,19 +581,61 @@ jobs:
503581
if: needs.release.result == 'success'
504582

505583
steps:
506-
- name: Check chocolatey publish secret
584+
- name: Resolve chocolatey publish policy
585+
id: policy
586+
shell: pwsh
587+
run: |
588+
$ErrorActionPreference = "Stop"
589+
$dryRun = "${{ github.event_name }}" -eq "workflow_dispatch" -and "${{ github.event.inputs.dry_run_publish }}" -eq "true"
590+
"dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
591+
592+
- name: Check chocolatey publish policy
507593
id: gate
508594
shell: pwsh
509595
env:
510596
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
511597
run: |
512-
if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY))
598+
$ErrorActionPreference = "Stop"
599+
$logsDir = "release-channel-logs"
600+
New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
601+
$packLogPath = Join-Path $logsDir "choco-pack-log.txt"
602+
$pushLogPath = Join-Path $logsDir "choco-push-log.txt"
603+
$dryRun = "${{ steps.policy.outputs.dry_run }}" -eq "true"
604+
605+
if ($dryRun)
513606
{
514-
Write-Host "Chocolatey API key missing. Skipping publish-chocolatey job."
607+
$summary = @(
608+
"## chocolatey publish",
609+
"- attempted: false",
610+
"- version: ${{ needs.build.outputs.version }}",
611+
"- result: dry-run",
612+
"- reason: workflow_dispatch requested dry_run_publish=true"
613+
)
614+
615+
$summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
616+
"Dry run requested. Chocolatey pack validation and push intentionally skipped." | Set-Content -Path $packLogPath -Encoding utf8
617+
"Dry run requested. Chocolatey push intentionally skipped." | Set-Content -Path $pushLogPath -Encoding utf8
515618
"enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
516619
exit 0
517620
}
518621
622+
if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY))
623+
{
624+
$message = "CHOCOLATEY_API_KEY is missing. Refusing to report a green public release without a package publish attempt."
625+
$summary = @(
626+
"## chocolatey publish",
627+
"- attempted: false",
628+
"- version: ${{ needs.build.outputs.version }}",
629+
"- result: failed-policy",
630+
"- reason: missing CHOCOLATEY_API_KEY"
631+
)
632+
633+
$summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
634+
$message | Set-Content -Path $packLogPath -Encoding utf8
635+
$message | Set-Content -Path $pushLogPath -Encoding utf8
636+
throw $message
637+
}
638+
519639
"enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
520640
521641
- name: Checkout
@@ -529,21 +649,93 @@ jobs:
529649
name: release-installer
530650
path: release-assets/installer
531651

652+
- name: Validate chocolatey package
653+
if: steps.gate.outputs.enabled == 'true'
654+
shell: pwsh
655+
run: |
656+
$ErrorActionPreference = "Stop"
657+
$logsDir = (Resolve-Path "release-channel-logs").Path
658+
$packLogPath = Join-Path $logsDir "choco-pack-log.txt"
659+
$artifactRoot = "release-channel-artifacts/chocolatey"
660+
New-Item -ItemType Directory -Force -Path $artifactRoot | Out-Null
661+
662+
$installer = Get-ChildItem "release-assets/installer/*.exe" -File | Select-Object -First 1
663+
if (-not $installer)
664+
{
665+
throw "Installer executable not found for Chocolatey validation."
666+
}
667+
668+
& ./build/publish-chocolatey.ps1 `
669+
-Version "${{ needs.build.outputs.version }}" `
670+
-Tag "${{ needs.build.outputs.tag }}" `
671+
-InstallerPath $installer.FullName `
672+
-DryRun `
673+
-PackageOutputDirectory $artifactRoot `
674+
-MetadataOutputPath (Join-Path $artifactRoot "chocolatey-package-metadata.json") *>&1 | Tee-Object -FilePath $packLogPath
675+
676+
if ($LASTEXITCODE -ne 0)
677+
{
678+
throw "Chocolatey dry-run validation failed with exit code $LASTEXITCODE"
679+
}
680+
532681
- name: Publish chocolatey package
533682
if: steps.gate.outputs.enabled == 'true'
534683
shell: pwsh
535684
env:
536685
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
537686
run: |
538687
$ErrorActionPreference = "Stop"
688+
$logsDir = (Resolve-Path "release-channel-logs").Path
689+
$pushLogPath = Join-Path $logsDir "choco-push-log.txt"
690+
$artifactRoot = "release-channel-artifacts/chocolatey"
691+
New-Item -ItemType Directory -Force -Path $artifactRoot | Out-Null
539692
$installer = Get-ChildItem "release-assets/installer/*.exe" -File | Select-Object -First 1
540693
if (-not $installer)
541694
{
542695
throw "Installer executable not found for Chocolatey publish."
543696
}
544697
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"
698+
try
699+
{
700+
& ./build/publish-chocolatey.ps1 `
701+
-Version "${{ needs.build.outputs.version }}" `
702+
-Tag "${{ needs.build.outputs.tag }}" `
703+
-InstallerPath $installer.FullName `
704+
-ApiKey "$env:CHOCOLATEY_API_KEY" `
705+
-PackageOutputDirectory $artifactRoot `
706+
-MetadataOutputPath (Join-Path $artifactRoot "chocolatey-publish-metadata.json") *>&1 | Tee-Object -FilePath $pushLogPath
707+
708+
if ($LASTEXITCODE -ne 0)
709+
{
710+
throw "publish-chocolatey.ps1 exited with code $LASTEXITCODE"
711+
}
712+
713+
@(
714+
"## chocolatey publish",
715+
"- attempted: true",
716+
"- version: ${{ needs.build.outputs.version }}",
717+
"- result: published"
718+
) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
719+
}
720+
catch
721+
{
722+
@(
723+
"## chocolatey publish",
724+
"- attempted: true",
725+
"- version: ${{ needs.build.outputs.version }}",
726+
"- result: failed"
727+
) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
728+
throw
729+
}
730+
731+
- name: Upload chocolatey publish logs
732+
if: always()
733+
uses: actions/upload-artifact@v4
734+
with:
735+
name: chocolatey-publish-logs
736+
path: |
737+
release-channel-logs/choco-pack-log.txt
738+
release-channel-logs/choco-push-log.txt
739+
release-channel-artifacts/chocolatey/*.nupkg
740+
release-channel-artifacts/chocolatey/*.json
741+
if-no-files-found: error

0 commit comments

Comments
 (0)