Skip to content

Refuse Defender exclusions for script interpreters, pin ps2exe (#100) #623

Refuse Defender exclusions for script interpreters, pin ps2exe (#100)

Refuse Defender exclusions for script interpreters, pin ps2exe (#100) #623

Workflow file for this run

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.