Refuse Defender exclusions for script interpreters, pin ps2exe (#100) #623
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [master] | |
| pull_request: | |
| branches: [master] | |
| # Top-level: read-only. Write privileges granted only on the test job that needs them. | |
| # Scorecard's Token-Permissions check awards full credit for this least-privilege pattern. | |
| permissions: read-all | |
| jobs: | |
| test: | |
| permissions: | |
| contents: write # needed for the auto-release step (gh release create) | |
| id-token: write # needed for actions/attest-* (SLSA L3 provenance + SBOM) | |
| attestations: write # needed for actions/attest-* (persist the attestation) | |
| artifact-metadata: write # needed by actions/attest v4 (create the artifact storage record) | |
| actions: read # explicit; needed for run metadata in attestations | |
| # windows-2025 (GitHub-hosted) for both push and PR. RackStack is a public | |
| # repo — running on a self-hosted runner means anyone can fork, open a PR, | |
| # and execute arbitrary code on the runner host. windows-2025 is free and | |
| # unlimited for public repos, ships Windows PowerShell 5.1 + pwsh 7 + .NET | |
| # Framework 4.8 + Git, and avoids the security exposure entirely. Pinned | |
| # explicitly (rather than windows-latest) ahead of the 2026-06-15 image | |
| # migration so the build runs against a deterministic, validated image. | |
| runs-on: windows-2025 | |
| # Prevents a hung test from tying up the runner — full suite normally runs in ~3 min | |
| timeout-minutes: 20 | |
| defaults: | |
| run: | |
| shell: pwsh | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # Need at least 2 commits so the version-bump detection can diff Header.ps1 | |
| # against the previous commit. Default fetch-depth is 1 which makes git show HEAD~1 fail. | |
| fetch-depth: 2 | |
| - name: Install PSScriptAnalyzer | |
| run: | | |
| if (-not (Get-Module PSScriptAnalyzer -ListAvailable)) { | |
| Install-Module PSScriptAnalyzer -Force -Scope CurrentUser | |
| } | |
| - name: Install Pester 5.x | |
| run: | | |
| $hasPester5 = Get-Module Pester -ListAvailable | Where-Object { $_.Version -ge [version]'5.0.0' } | |
| if (-not $hasPester5) { | |
| Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser | |
| } | |
| - name: Generate monolithic | |
| run: .\sync-to-monolithic.ps1 | |
| - name: PSScriptAnalyzer | |
| run: .\Tests\pssa-check.ps1 | |
| - name: Parse check | |
| run: .\Tests\parse-check.ps1 | |
| - name: Run tests (core) | |
| env: | |
| RACKSTACK_CI: 'true' | |
| run: | | |
| # Core tests: parse, module load, function existence, validation, navigation | |
| & .\Tests\Run-Tests.ps1 -Quick | |
| exit $LASTEXITCODE | |
| - name: Run Pester unit tests | |
| run: | | |
| # Pester 5.x unit tests for pure functions (input validation, navigation, password | |
| # complexity, formatters). Complements the regex-pattern harness in Run-Tests.ps1. | |
| # Also emits JaCoCo code-coverage XML at Tests/coverage.xml for Codecov upload. | |
| & .\Tests\pester-check.ps1 | |
| exit $LASTEXITCODE | |
| - name: Upload coverage to Codecov | |
| # Best-effort: Codecov has occasional outages, and missing coverage is not worth | |
| # failing CI over. `continue-on-error` so the release pipeline still proceeds. | |
| if: always() && hashFiles('Tests/coverage.xml') != '' | |
| continue-on-error: true | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| files: Tests/coverage.xml | |
| flags: pester | |
| fail_ci_if_error: false | |
| # CODECOV_TOKEN secret is optional for public repos — Codecov accepts | |
| # tokenless uploads for OSS, just slower. Set the secret to skip queuing. | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| - name: Upload test artifacts on failure | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| if: failure() | |
| with: | |
| name: test-failure-logs | |
| path: | | |
| builds/RackStack v*.ps1 | |
| Tests/*.log | |
| retention-days: 7 | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # Auto-release on version bump (push to master only). Detects whether | |
| # Header.ps1 .VERSION changed since the previous commit; if so, compiles | |
| # the EXE, generates SHA256 hashes, and publishes a GitHub Release with | |
| # this version's changelog entry. Closes the gap that made v1.98.1-v1.98.5 | |
| # commits without releases. | |
| # ───────────────────────────────────────────────────────────────────────── | |
| - name: Detect version bump | |
| id: vercheck | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/master' | |
| run: | | |
| # Read both Header.ps1 versions as single strings and match with (?m) so | |
| # CRLF / array-pipeline quirks across PS 5 vs PS 7 don't matter. | |
| $curText = Get-Content Header.ps1 -Raw | |
| $cur = if ($curText -match '(?ms)^\.VERSION\s*\r?\n\s+(\d+\.\d+\.\d+)') { $matches[1] } else { '' } | |
| $prev = '' | |
| $prevText = (git show HEAD~1:Header.ps1 2>$null) -join "`n" | |
| # `git show` failure for HEAD~1 (e.g., shallow clone, first commit) is a non-fatal | |
| # signal — treat as "no prev version known" and let the bump check handle it. | |
| $global:LASTEXITCODE = 0 | |
| if ($prevText -and $prevText.Length -gt 0) { | |
| if ($prevText -match '(?ms)^\.VERSION\s*\r?\n\s+(\d+\.\d+\.\d+)') { $prev = $matches[1] } | |
| } | |
| $bumped = ($cur -and $cur -ne $prev) | |
| $bumpedStr = if ($bumped) { 'true' } else { 'false' } | |
| Write-Host "Previous version: '$prev'" | |
| Write-Host "Current version: '$cur'" | |
| Write-Host "Bumped: $bumpedStr" | |
| "version=$cur" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| "prev=$prev" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| "bumped=$bumpedStr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| # Reset LASTEXITCODE so any earlier native-command exit doesn't propagate as our exit | |
| $global:LASTEXITCODE = 0 | |
| exit 0 | |
| - name: Check release does not already exist | |
| id: releasecheck | |
| if: steps.vercheck.outputs.bumped == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $existing = gh release view "v$ver" --json tagName 2>$null | |
| $global:LASTEXITCODE = 0 # `gh release view` exits non-zero when release missing — that's expected here | |
| $existsBool = -not [string]::IsNullOrWhiteSpace($existing) | |
| $existsStr = if ($existsBool) { 'true' } else { 'false' } | |
| Write-Host "Release v$ver already exists: $existsStr" | |
| "exists=$existsStr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| exit 0 | |
| - name: Install ps2exe | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| run: | | |
| # Pinned. This module compiles the binary that ships to users, so an | |
| # unpinned install would let the released artifact change without a | |
| # commit — the same supply-chain exposure the SHA-pinning policy | |
| # closes for actions. Bump deliberately, never implicitly. | |
| $ps2exeVersion = '1.0.18' | |
| if (-not (Get-Module ps2exe -ListAvailable | Where-Object { $_.Version -eq $ps2exeVersion })) { | |
| Install-Module ps2exe -RequiredVersion $ps2exeVersion -Force -Scope CurrentUser -AllowClobber | |
| } | |
| Import-Module ps2exe -RequiredVersion $ps2exeVersion -Force | |
| - name: Compile RackStack.exe | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $mono = "builds\RackStack v$ver.ps1" | |
| if (-not (Test-Path $mono)) { throw "Monolithic not found at $mono" } | |
| if (-not (Test-Path 'RackStack.ico')) { throw "RackStack.ico missing — required for compile" } | |
| Invoke-PS2EXE -InputFile $mono -OutputFile 'builds\RackStack.exe' -Version $ver -RequireAdmin -IconFile 'RackStack.ico' | |
| $info = Get-Item 'builds\RackStack.exe' | |
| Write-Host "Compiled: $($info.FullName) ($([math]::Round($info.Length / 1MB, 2)) MB)" | |
| # Release integrity is provided by SHA-256 hashes, Sigstore cosign | |
| # keyless signatures, and SLSA Level 3 build provenance (all below). | |
| # The EXE is not Authenticode-signed — Windows SmartScreen may show an | |
| # "Unknown publisher" prompt on first run until the project builds | |
| # enough download reputation. | |
| - name: Generate SHA256 hashes | |
| id: hashes | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $assets = @( | |
| @{ Name = 'RackStack.exe'; Path = 'builds\RackStack.exe' } | |
| @{ Name = "RackStack v$ver.ps1"; Path = "builds\RackStack v$ver.ps1" } | |
| @{ Name = 'rackstack.config.example.json'; Path = 'rackstack.config.example.json' } | |
| ) | |
| $lines = foreach ($a in $assets) { | |
| $h = (Get-FileHash -LiteralPath $a.Path -Algorithm SHA256).Hash.ToLower() | |
| "{0} {1}" -f $h, $a.Name | |
| } | |
| $lines | Set-Content 'release-hashes.txt' -Encoding utf8 | |
| Get-Content 'release-hashes.txt' | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # Supply-chain attestations: SLSA Level 3 build provenance + SBOM. | |
| # The provenance attestation cryptographically links the EXE to this exact | |
| # GitHub Actions workflow run on this exact commit — consumers can verify | |
| # via `gh attestation verify RackStack.exe --owner TheAbider`. | |
| # ───────────────────────────────────────────────────────────────────────── | |
| - name: Attest build provenance (SLSA Level 3) | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-path: | | |
| builds/RackStack.exe | |
| builds/RackStack v${{ steps.vercheck.outputs.version }}.ps1 | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # Sigstore cosign keyless signing — produces .sig files alongside each | |
| # release artifact. Keyless mode uses GitHub OIDC + Sigstore's Fulcio CA | |
| # to issue a short-lived certificate tied to this workflow run; no long- | |
| # lived signing key to manage. Combined with SLSA provenance, this gives | |
| # the project credit for the OpenSSF Scorecard "Signed-Releases" check. | |
| # ───────────────────────────────────────────────────────────────────────── | |
| - name: Install cosign | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| with: | |
| # Pin the cosign CLI to v2.x. The signing step below uses | |
| # sign-blob --output-signature / --output-certificate to emit the | |
| # detached .sig + .pem alongside each artifact. cosign v3 removed | |
| # those flags in favour of --bundle, so an unpinned installer would | |
| # silently break release signing. Keep the installer action current | |
| # while holding the CLI on the flags this workflow relies on. | |
| cosign-release: 'v2.5.2' | |
| - name: Sign release artifacts with cosign (keyless) | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $assets = @( | |
| 'builds\RackStack.exe' | |
| "builds\RackStack v$ver.ps1" | |
| 'release-hashes.txt' | |
| ) | |
| foreach ($a in $assets) { | |
| if (-not (Test-Path -LiteralPath $a)) { | |
| Write-Host "Skipping (not present): $a" | |
| continue | |
| } | |
| $sigPath = "$a.sig" | |
| $certPath = "$a.pem" | |
| Write-Host "Signing $a..." | |
| cosign sign-blob --yes ` | |
| --output-signature $sigPath ` | |
| --output-certificate $certPath ` | |
| $a | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warning "cosign sign-blob failed for $a (exit $LASTEXITCODE)" | |
| } else { | |
| Write-Host " -> $sigPath + $certPath" | |
| } | |
| } | |
| # Reset exit code so a single sign-blob failure doesn't abort the release flow. | |
| $global:LASTEXITCODE = 0 | |
| exit 0 | |
| - name: Generate SBOM (CycloneDX) | |
| id: sbom | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| continue-on-error: true | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 | |
| with: | |
| # Syft can't resolve a single Windows PE .exe as a source (needs a | |
| # directory or recognized package format). Scan the whole repo | |
| # checkout instead — that catches the PowerShell sources, the | |
| # .psd1 manifest's PowerShellVersion declaration, and the | |
| # monolithic .ps1 that the EXE is compiled from. The result is a | |
| # source-level SBOM rather than a binary one, which is more | |
| # useful for downstream consumers anyway. | |
| path: . | |
| format: cyclonedx-json | |
| artifact-name: RackStack-${{ steps.vercheck.outputs.version }}-sbom.cyclonedx.json | |
| output-file: builds/RackStack-${{ steps.vercheck.outputs.version }}-sbom.cyclonedx.json | |
| # Don't fail the release if the SBOM step trips — supply chain attestation | |
| # is supplementary; the EXE + hashes + provenance attestation are the | |
| # primary integrity guarantees. | |
| upload-artifact: true | |
| upload-release-assets: false | |
| # actions/attest-sbom was deprecated; actions/attest exposes a native | |
| # `sbom-path` input (SPDX/CycloneDX, auto-detected) that maps 1:1 from the | |
| # old action and produces an identical SBOM attestation — verifiable via | |
| # `gh attestation verify`. Do NOT add predicate-* here: sbom-path is | |
| # mutually exclusive with them. | |
| - name: Attest SBOM | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' && hashFiles(format('builds/RackStack-{0}-sbom.cyclonedx.json', steps.vercheck.outputs.version)) != '' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-path: builds/RackStack.exe | |
| sbom-path: builds/RackStack-${{ steps.vercheck.outputs.version }}-sbom.cyclonedx.json | |
| - name: Build release notes | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| # Extract this version's section from Changelog.md (## vX.Y.Z ... next ## or EOF) | |
| $changelogRaw = Get-Content 'Changelog.md' -Raw | |
| $pat = "(?ms)^## v$([regex]::Escape($ver))\s*\r?\n(.+?)(?=^## v|\z)" | |
| $entry = if ($changelogRaw -match $pat) { $matches[1].Trim() } else { "Auto-generated release for v$ver." } | |
| $hashes = Get-Content 'release-hashes.txt' -Raw | |
| $body = @" | |
| # RackStack v$ver | |
| Auto-released by CI on version bump in ``Header.ps1``. | |
| ## SHA256 verification | |
| `````` | |
| $($hashes.TrimEnd()) | |
| `````` | |
| Verify with ``(Get-FileHash RackStack.exe -Algorithm SHA256).Hash.ToLower()``. | |
| ## Supply-chain attestations | |
| - **Build provenance** (SLSA Level 3): verify the EXE came from this exact workflow run on this commit: | |
| `````` | |
| gh attestation verify RackStack.exe --owner TheAbider | |
| `````` | |
| - **Cosign keyless signatures**: each artifact is signed via Sigstore Fulcio + GitHub OIDC. Verify with: | |
| `````` | |
| cosign verify-blob \`` | |
| --certificate RackStack.exe.pem \`` | |
| --signature RackStack.exe.sig \`` | |
| --certificate-identity-regexp "^https://github.com/TheAbider/RackStack/.github/workflows/ci.yml@refs/heads/master$" \`` | |
| --certificate-oidc-issuer https://token.actions.githubusercontent.com \`` | |
| RackStack.exe | |
| `````` | |
| - **SBOM** (CycloneDX): see ``RackStack-$ver-sbom.cyclonedx.json`` attached below. | |
| ## Changes | |
| $entry | |
| "@ | |
| $body | Set-Content 'release-body.md' -Encoding utf8 | |
| - name: Create GitHub Release | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| # Retry up to 3 times on transient gh / api.github.com network errors. The | |
| # self-hosted runner has seen wsarecv resets from api.github.com mid-call | |
| # ("read tcp ...: An existing connection was forcibly closed by the remote | |
| # host") that abort gh BEFORE the release is actually created. Re-checking | |
| # via `gh release view` between attempts short-circuits if a prior attempt | |
| # successfully created the release despite reporting non-zero exit. | |
| $maxAttempts = 3 | |
| $attempt = 0 | |
| $created = $false | |
| while (-not $created -and $attempt -lt $maxAttempts) { | |
| $attempt++ | |
| Write-Host "Release attempt $attempt/$maxAttempts..." | |
| $global:LASTEXITCODE = 0 | |
| $existsCheck = gh release view "v$ver" --json tagName 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $existsCheck) { | |
| Write-Host "Release v$ver already exists (created on a prior attempt). Done." | |
| $created = $true | |
| break | |
| } | |
| $global:LASTEXITCODE = 0 | |
| # Build the asset list dynamically so optional files (SBOM + cosign | |
| # .sig/.pem) are only attached when they exist. Scorecard's | |
| # Signed-Releases check looks for .sig files on every release artifact. | |
| $assetArgs = @( | |
| "builds\RackStack.exe" | |
| "builds\RackStack v$ver.ps1" | |
| "rackstack.config.example.json" | |
| 'release-hashes.txt' | |
| ) | |
| $sbomPath = "builds\RackStack-$ver-sbom.cyclonedx.json" | |
| if (Test-Path -LiteralPath $sbomPath) { $assetArgs += $sbomPath } | |
| # Cosign keyless signatures + certificates (one of each per artifact). | |
| foreach ($base in @("builds\RackStack.exe", "builds\RackStack v$ver.ps1", 'release-hashes.txt')) { | |
| foreach ($suffix in @('.sig', '.pem')) { | |
| $sigFile = "$base$suffix" | |
| if (Test-Path -LiteralPath $sigFile) { $assetArgs += $sigFile } | |
| } | |
| } | |
| gh release create "v$ver" ` | |
| --title "RackStack v$ver" ` | |
| --notes-file 'release-body.md' ` | |
| @assetArgs | |
| $createExit = $LASTEXITCODE | |
| if ($createExit -eq 0) { | |
| $created = $true | |
| Write-Host "Release v$ver published on attempt $attempt." | |
| } else { | |
| Write-Host "Attempt $attempt failed (gh exit $createExit). Retrying in 10s..." | |
| Start-Sleep -Seconds 10 | |
| } | |
| } | |
| if (-not $created) { | |
| Write-Error "Failed to create release v$ver after $maxAttempts attempts." | |
| exit 1 | |
| } | |
| $global:LASTEXITCODE = 0 | |
| - name: Publish to PowerShell Gallery | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| env: | |
| PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} | |
| run: | | |
| # Publishes the thin-wrapper module (RackStack.psd1 / RackStack.psm1) to PowerShell | |
| # Gallery so users can `Install-Module RackStack`. The EXE is still downloaded | |
| # separately at first use — the gallery module locates it via Get-RackStackExePath. | |
| # Gated on the PSGALLERY_API_KEY secret existing; skips silently if absent so the | |
| # release pipeline still works on forks / first-run before the secret is set. | |
| if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_API_KEY)) { | |
| Write-Host "PSGALLERY_API_KEY secret not set — skipping PowerShell Gallery publish." | |
| Write-Host "To enable: add the secret in Settings → Secrets → Actions, then re-run." | |
| exit 0 | |
| } | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| # Verify the .psd1 ModuleVersion matches the .VERSION in Header.ps1 — both should be $ver. | |
| $psd = Import-PowerShellDataFile -Path 'RackStack.psd1' | |
| if ($psd.ModuleVersion -ne $ver) { | |
| Write-Error "PSGallery publish: RackStack.psd1 ModuleVersion ($($psd.ModuleVersion)) does not match Header.ps1 version ($ver). Bump the manifest." | |
| exit 1 | |
| } | |
| # Stage the module into a clean directory (PSGallery wants module-name-as-dirname). | |
| $stageDir = Join-Path $env:RUNNER_TEMP "RackStack-publish-$ver" | |
| if (Test-Path -LiteralPath $stageDir) { Remove-Item -LiteralPath $stageDir -Recurse -Force } | |
| $modDir = Join-Path $stageDir 'RackStack' | |
| New-Item -Path $modDir -ItemType Directory -Force | Out-Null | |
| Copy-Item -LiteralPath 'RackStack.psd1' -Destination $modDir -Force | |
| Copy-Item -LiteralPath 'RackStack.psm1' -Destination $modDir -Force | |
| # Publish — Publish-Module on PS 7.x uses NuGet under the hood; supports -SkipAutomaticTags. | |
| try { | |
| Publish-Module -Path $modDir -NuGetApiKey $env:PSGALLERY_API_KEY -ErrorAction Stop | |
| Write-Host "Published RackStack v$ver to PowerShell Gallery." | |
| } catch { | |
| Write-Host "PSGallery publish failed: $($_.Exception.Message)" | |
| # Non-fatal — the GitHub Release still publishes; PSGallery is a secondary channel. | |
| # Operator can re-run manually with `Publish-Module -Path ... -NuGetApiKey ...`. | |
| } | |
| exit 0 | |
| # Detect which publish-channel tokens are configured. The `secrets` | |
| # context is not reliably usable in step-level `if:` conditions, so | |
| # presence is checked here and exposed as step outputs the winget / | |
| # chocolatey steps gate on. | |
| - name: Detect publish tokens | |
| id: pubtokens | |
| if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' | |
| env: | |
| WT: ${{ secrets.WINGET_TOKEN }} | |
| CT: ${{ secrets.CHOCO_API_KEY }} | |
| run: | | |
| $w = if ([string]::IsNullOrWhiteSpace($env:WT)) { 'false' } else { 'true' } | |
| $c = if ([string]::IsNullOrWhiteSpace($env:CT)) { 'false' } else { 'true' } | |
| "winget=$w" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| "choco=$c" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| Write-Host "winget token: $w choco token: $c" | |
| # Submit to winget-pkgs. Inlined into the release job (rather than a | |
| # separate workflow on `release: published`) because a release created | |
| # with GITHUB_TOKEN does NOT trigger `release` event workflows — GitHub's | |
| # recursion-prevention. Running it here fires exactly when a release is | |
| # cut. continue-on-error so a winget hiccup never fails the release. | |
| # | |
| # Uses Microsoft's wingetcreate CLI directly rather than a winget | |
| # submission Action — the popular winget-releaser Action pulls in an | |
| # unpinned transitive action (cargo-bins/cargo-binstall@main), which the | |
| # repo's SHA-pinning policy correctly rejects. wingetcreate is a single | |
| # signed .exe with no nested actions, so it stays inside the policy. | |
| # | |
| # `wingetcreate update` requires the package to already exist in | |
| # winget-pkgs. The FIRST submission of TheAbider.RackStack is a one-time | |
| # manual step (`wingetcreate new` locally, or a hand-authored manifest | |
| # PR — see dist/README.md). Every release after that is automated here. | |
| - name: Submit to winget-pkgs | |
| if: steps.pubtokens.outputs.winget == 'true' | |
| continue-on-error: true | |
| env: | |
| WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $exeUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/v$ver/RackStack.exe" | |
| $wc = Join-Path $env:RUNNER_TEMP 'wingetcreate.exe' | |
| Write-Host "Downloading wingetcreate..." | |
| Invoke-WebRequest -Uri 'https://aka.ms/wingetcreate/latest' -OutFile $wc -UseBasicParsing | |
| Write-Host "Submitting TheAbider.RackStack v$ver to winget-pkgs..." | |
| & $wc update TheAbider.RackStack --version $ver --urls $exeUrl --submit --token $env:WINGET_TOKEN | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Host "wingetcreate exit $LASTEXITCODE — if this is the first-ever submission," | |
| Write-Host "the package does not exist in winget-pkgs yet; do the first one manually" | |
| Write-Host "(see dist/README.md). Subsequent releases will auto-submit." | |
| } | |
| $global:LASTEXITCODE = 0 | |
| exit 0 | |
| # Publish to the Chocolatey Community Repository. Inlined for the same | |
| # reason as winget above. The EXE is already built in builds\ for this | |
| # run, so no download is needed. | |
| - name: Publish to Chocolatey | |
| if: steps.pubtokens.outputs.choco == 'true' | |
| continue-on-error: true | |
| env: | |
| CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} | |
| run: | | |
| $ver = '${{ steps.vercheck.outputs.version }}' | |
| $exe = 'builds\RackStack.exe' | |
| if (-not (Test-Path -LiteralPath $exe)) { | |
| Write-Host 'RackStack.exe not present — skipping Chocolatey publish.' | |
| exit 0 | |
| } | |
| $hash = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower() | |
| Push-Location dist/chocolatey | |
| # Stamp the templated nuspec + install script with this version + hash. | |
| (Get-Content 'rackstack.nuspec' -Raw) -replace '<version>0\.0\.0</version>', "<version>$ver</version>" | | |
| Set-Content 'rackstack.nuspec' -Encoding utf8 | |
| (Get-Content 'tools/chocolateyinstall.ps1' -Raw) ` | |
| -replace '__VERSION__', $ver ` | |
| -replace '__CHECKSUM_SHA256__', $hash | | |
| Set-Content 'tools/chocolateyinstall.ps1' -Encoding utf8 | |
| choco pack rackstack.nuspec | |
| $nupkg = Get-ChildItem -Filter 'rackstack.*.nupkg' | Select-Object -First 1 | |
| if ($nupkg) { | |
| Write-Host "Pushing $($nupkg.Name) to Chocolatey..." | |
| choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/ | |
| choco push $nupkg.FullName --source https://push.chocolatey.org/ | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Host "choco push exit $LASTEXITCODE — first submissions queue for moderation." | |
| } | |
| } else { | |
| Write-Host "choco pack produced no .nupkg." | |
| } | |
| $global:LASTEXITCODE = 0 | |
| Pop-Location | |
| exit 0 | |
| # NO RELEASE RETENTION / DELETION STEP — deliberately removed, do not re-add. | |
| # | |
| # A retention step used to delete every older patch release within the same minor so | |
| # that only the newest survived. That silently broke the package managers, because a | |
| # published package manifest pins the release asset URL for its own version and those | |
| # registries keep approved versions forever: | |
| # | |
| # - Chocolatey: the approved rackstack 1.99.0 package downloads | |
| # .../releases/download/v1.99.0/RackStack.exe via Get-ChocolateyWebFile. Retention | |
| # deleted that release when v1.99.1 shipped, so `choco install rackstack` returned | |
| # 404 for roughly two months before anyone noticed (found 2026-07-29). | |
| # - Chocolatey moderation: v1.122.2 failed automated validation with "unable to find | |
| # a package" because v1.122.3 published while v1.122.2 was still in the review | |
| # queue, and retention deleted the release out from under the moderator. | |
| # - winget: the same hazard was already documented in dist/winget/README.md. | |
| # | |
| # GitHub release assets on a public repo do not count against the Actions artifact | |
| # storage quota, so there is no storage argument for deleting them. If disk hygiene | |
| # ever matters, prune Actions *artifacts* — never releases that a published package | |
| # points at. Run-Tests section 206 asserts this workflow contains no release deletion. |