forked from jimrtyler/Guerrilla
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPublish-Release.ps1
More file actions
235 lines (215 loc) · 13.9 KB
/
Copy pathPublish-Release.ps1
File metadata and controls
235 lines (215 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#requires -version 7.0
<#
.SYNOPSIS
The ONLY supported way to publish Guerrilla to PSGallery. Mechanically couples
a release to green: runs the golden-fixture detection suite AND the collector
query-contract tests, and refuses to publish if either is red (or the tree is dirty).
.DESCRIPTION
Gate -> validate -> stage-clean -> publish. A release cannot leave this script while
a test is failing, which closes the "human ignores the Actions tab" gap for the
documented path. Publishing is rare, so it runs both the verdict-logic suite and the
endpoint-drift (contract) suite — a release is exactly when you want drift checked.
Do NOT publish with a bare `Publish-PSResource` — that bypasses the gate. This script
is the documented path in the repo and the release runbook.
.PARAMETER ApiKey
PSGallery key. Defaults to $env:PSGALLERY_KEY. Never hardcode; never commit.
.PARAMETER DryRun
Run every gate + stage + manifest validation and report what WOULD publish, without
publishing. Use this to prove the guard end-to-end without a key.
.EXAMPLE
pwsh ./Publish-Release.ps1 -DryRun
.EXAMPLE
$env:PSGALLERY_KEY = '<key>'; pwsh ./Publish-Release.ps1
#>
[CmdletBinding()]
param(
[string]$ApiKey = $env:PSGALLERY_KEY,
[string]$Repository = 'PSGallery',
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
function Fail($m) { Write-Host "ABORT: $m" -ForegroundColor Red; exit 1 }
function Ok($m) { Write-Host " [ok] $m" -ForegroundColor Green }
$version = (Import-PowerShellDataFile (Join-Path $root 'source' 'Guerrilla.psd1')).ModuleVersion
Write-Host "== Publish-Release: Guerrilla $version ($([string](& git -C $root rev-parse --short HEAD))) ==" -ForegroundColor Cyan
# 0) Clean, committed tree — a release ships a known SHA, not a working copy.
if (& git -C $root status --porcelain) { Fail 'working tree is dirty. Commit or stash before releasing.' }
Ok 'working tree clean'
# 0b) GATE SELF-TESTS — before trusting four green gates, prove each can go red:
# an injected failure must surface as a non-zero exit through the same invocation
# shape each gate uses (gate C's poison self-test lives inside its own test file).
Write-Host "-- gate self-tests: injected failure must exit non-zero --"
& pwsh -NoProfile -File (Join-Path $root 'Tests' 'Invoke-GatePoisonSelfTests.ps1') | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "gate poison self-test RED (exit $LASTEXITCODE) — a gate cannot prove it fails; its green is meaningless. Release blocked." }
Ok 'gates A, B, D, E proved they can fail (C self-tests in-file)'
# 1) GATE A — golden-fixture detection suite (verdict logic). Child process so its exit() can't kill us.
# Emits test-summary.json (gitignored): the derived source of every public number,
# regenerated fresh at release time — gate E reconciles the prose against it.
Write-Host "-- gate A: golden-fixture detection suite --"
$summaryPath = Join-Path $root 'Tests' 'test-summary.json'
& pwsh -NoProfile -File (Join-Path $root 'Tests' 'Invoke-FixtureTests.ps1') -EmitSummary $summaryPath | Select-Object -Last 3 | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "golden-fixture suite RED (exit $LASTEXITCODE) — release blocked." }
if (-not (Test-Path $summaryPath)) { Fail 'gate A exited green but produced no test-summary.json — the derived-counts artifact is the release contract.' }
Ok 'golden-fixture suite green (artifact emitted)'
# 1b) GATE E — public numbers must reconcile with the artifact gate A just derived.
# Catches both stale prose after a catalog change and a version-mismatched artifact.
Write-Host "-- gate E: derived-counts reconciliation (manifest Description + README vs artifact) --"
& pwsh -NoProfile -File (Join-Path $root 'Tests' 'Invoke-CountReconciliation.ps1') -SummaryPath $summaryPath | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "derived-counts reconciliation RED (exit $LASTEXITCODE) — a public number does not match the gating run. Release blocked." }
Ok 'public numbers reconcile with the derived artifact'
# 2) GATE B — collector query-contract tests (endpoint/param drift).
Write-Host "-- gate B: collector query-contract tests --"
$contract = Join-Path $root 'Tests' 'Unit' 'Private' 'Entra' 'CollectorQueryContract.Tests.ps1'
# A gate whose test file has vanished must abort, not warn-and-skip: a silently
# absent gate reads exactly like a green one.
if (-not (Test-Path $contract)) { Fail "contract tests not found at $contract — gate B cannot run. Release blocked." }
& pwsh -NoProfile -c "`$r = Invoke-Pester -Path '$contract' -Output None -PassThru; 'contract: '+`$r.PassedCount+' passed, '+`$r.FailedCount+' failed'; exit `$r.FailedCount" | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "collector contract tests RED (exit $LASTEXITCODE) — release blocked." }
Ok 'collector contract tests green'
# 2c) GATE C — Zero Trust schema (every check must declare pillar + weight).
Write-Host "-- gate C: Zero Trust check-definition schema --"
$ztSchema = Join-Path $root 'Tests' 'Unit' 'ZeroTrustSchema.Tests.ps1'
& pwsh -NoProfile -c "`$r = Invoke-Pester -Path '$ztSchema' -Output None -PassThru; exit `$r.FailedCount" | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "Zero Trust schema RED — a check is missing pillar/weight. Release blocked." }
Ok 'Zero Trust schema green (all checks declare pillar + weight)'
# 2d) GATE D — full unit suite. Red anywhere blocks publish: a suite the release
# routes around stops meaning anything (Windows-only surfaces self-skip off Windows).
Write-Host "-- gate D: full unit suite --"
& pwsh -NoProfile -c "`$r = Invoke-Pester -Path (Join-Path '$root' 'Tests' 'Unit') -Output None -PassThru; 'unit: '+`$r.PassedCount+' passed, '+`$r.FailedCount+' failed, '+`$r.SkippedCount+' skipped'; exit `$r.FailedCount" | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "unit suite RED (exit $LASTEXITCODE) — release blocked." }
Ok 'unit suite green'
# 3) Manifest validity + ReleaseNotes length.
$null = Test-ModuleManifest (Join-Path $root 'source' 'Guerrilla.psd1')
$rn = (Import-PowerShellDataFile (Join-Path $root 'source' 'Guerrilla.psd1')).PrivateData.PSData.ReleaseNotes
if ($rn.Length -ge 10000) { Fail "ReleaseNotes is $($rn.Length) chars (PSGallery limit 10000)." }
Ok "manifest valid; ReleaseNotes $($rn.Length) chars"
# 4) Stage a clean copy from HEAD (NOT the working tree). Exclude dev/test/CI + the analyzer
# dot-file (PSResourceGet grabs the first .psd1 alphabetically — the leading-dot file has no
# Author and dies with a misleading 'No author' error) + this release script itself.
$stage = Join-Path ([System.IO.Path]::GetTempPath()) "psg-release-$version"
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
$pkg = Join-Path $stage 'Guerrilla'
New-Item -ItemType Directory -Path $pkg -Force | Out-Null
# The distributable module IS the source/ subtree: manifest, public/, internal/,
# checks/, Data/, format file. Archive that subtree as the package root, so repo-only
# artifacts (Tests/, Samples/, Config/, docs/, .github/, action.yml) never ship.
# Write the archive to a file first — piping `git archive | tar` through the PowerShell
# pipeline corrupts the binary tar stream. -o avoids the pipe entirely.
$tar = Join-Path $stage 'head.tar'
& git -C $root archive --format=tar -o $tar HEAD:source
& tar -xf $tar -C $pkg
Remove-Item $tar -Force -ErrorAction SilentlyContinue
# The standard docs live at the repo root, not in source/; copy them into the package.
foreach ($doc in 'README.md', 'LICENSE', 'CHANGELOG.md') {
$src = Join-Path $root $doc
if (Test-Path $src) { Copy-Item $src (Join-Path $pkg $doc) -Force }
}
$null = Test-ModuleManifest (Join-Path $pkg 'Guerrilla.psd1')
Ok "staged clean package at $pkg"
# 5) Pack the .nupkg (runs under -DryRun too, so packing is validated without a key).
$ProgressPreference = 'SilentlyContinue'
# Pack the .nupkg with .NET's OPC packager (System.IO.Packaging, the same
# container format NuGet uses) instead of Publish-PSResource, whose pack/cleanup
# stalls indefinitely on macOS at "Removed N of M files … 0.0 MB/s". This path
# has no progress/cleanup stage to hang on. Then PUSH with `dotnet nuget push`,
# which is fast and returns a clear 403 on a bad key.
Add-Type -AssemblyName System.IO.Packaging
$man = Import-PowerShellDataFile (Join-Path $root 'source' 'Guerrilla.psd1')
$ps = $man.PrivateData.PSData
$tags = (@('PSModule', 'PSEdition_Core') + @($ps.Tags)) -join ' '
$xe = { param($s) [System.Security.SecurityElement]::Escape([string]$s) }
$nuspec = @"
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Guerrilla</id>
<version>$version</version>
<authors>$(& $xe $man.Author)</authors>
<owners>$(& $xe $man.CompanyName)</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<licenseUrl>$(& $xe $ps.LicenseUri)</licenseUrl>
<projectUrl>$(& $xe $ps.ProjectUri)</projectUrl>
<description>$(& $xe $man.Description)</description>
<releaseNotes>$(& $xe $ps.ReleaseNotes)</releaseNotes>
<copyright>$(& $xe $man.Copyright)</copyright>
<tags>$(& $xe $tags)</tags>
</metadata>
</package>
"@
Set-Content -Path (Join-Path $pkg 'Guerrilla.nuspec') -Value $nuspec -Encoding utf8
$packDir = Join-Path $stage 'out'
New-Item -ItemType Directory -Path $packDir -Force | Out-Null
$nupkgPath = Join-Path $packDir "Guerrilla.$version.nupkg"
if (Test-Path $nupkgPath) { Remove-Item $nupkgPath -Force }
$opc = [System.IO.Packaging.Package]::Open($nupkgPath, [System.IO.FileMode]::Create)
try {
foreach ($f in Get-ChildItem -Path $pkg -Recurse -File) {
$rel = ($f.FullName.Substring($pkg.Length).TrimStart('/', '\')) -replace '\\', '/'
$partPath = '/' + ((($rel -split '/') | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/')
$uri = [System.IO.Packaging.PackUriHelper]::CreatePartUri([Uri]::new($partPath, [UriKind]::Relative))
$part = $opc.CreatePart($uri, 'application/octet', [System.IO.Packaging.CompressionOption]::Normal)
$dst = $part.GetStream(); $src = [System.IO.File]::OpenRead($f.FullName)
try { $src.CopyTo($dst) } finally { $src.Dispose(); $dst.Dispose() }
if ($rel -eq 'Guerrilla.nuspec') {
$null = $opc.CreateRelationship($uri, [System.IO.Packaging.TargetMode]::Internal, 'http://schemas.microsoft.com/packaging/2010/07/manifest')
}
}
$opc.PackageProperties.Creator = $man.Author
$opc.PackageProperties.Identifier = 'Guerrilla'
$opc.PackageProperties.Version = $version
$opc.PackageProperties.Keywords = $tags
$opc.PackageProperties.Description = $man.Description
} finally { $opc.Close() }
$nupkg = Get-Item $nupkgPath
if (-not $nupkg) { Fail 'packing produced no .nupkg.' }
Ok "packed $($nupkg.Name) ($([math]::Round($nupkg.Length/1MB,2)) MB)"
# 6) Dry run stops here: packed, gates green, nothing pushed. Otherwise get the key and push.
if ($DryRun) {
Write-Host "DRY RUN — all gates green, packed $($nupkg.Name). WOULD push Guerrilla $version to $Repository." -ForegroundColor Cyan
exit 0
}
if ([string]::IsNullOrWhiteSpace($ApiKey)) {
# Prompt HERE, in your terminal. -AsSecureString keeps it off screen and out of
# history/transcripts; it lives only in this process until the push, then is gone.
if ([Environment]::UserInteractive -or $Host.UI.RawUI) {
$sec = Read-Host 'PSGallery API key (hidden; paste here, not into chat)' -AsSecureString
$ApiKey = [System.Net.NetworkCredential]::new('', $sec).Password
}
if ([string]::IsNullOrWhiteSpace($ApiKey)) { Fail 'no ApiKey. Set $env:PSGALLERY_KEY or paste at the prompt — never commit it or put it in chat.' }
}
$dotnet = if (Get-Command dotnet -ErrorAction SilentlyContinue) { 'dotnet' }
elseif (Test-Path "$HOME/.dotnet/dotnet") { "$HOME/.dotnet/dotnet" }
else { $null }
if (-not $dotnet) { Fail 'dotnet SDK not found (needed to push). Install it, or add ~/.dotnet to PATH.' }
$pushUri = 'https://www.powershellgallery.com/api/v2/package'
& $dotnet nuget push $nupkg.FullName --api-key $ApiKey --source $pushUri --skip-duplicate
if ($LASTEXITCODE -ne 0) {
Fail ("push failed (exit $LASTEXITCODE). A 403 means the API key's glob scope does not cover 'Guerrilla' — " +
"mint a key at https://www.powershellgallery.com/account/apikeys with 'Push new packages and package versions' and glob '*'.")
}
Write-Host "PUBLISHED Guerrilla $version to $Repository." -ForegroundColor Green
# ── Tag + GitHub release so the repo and the Gallery don't diverge ──────────
# Historical gap flagged by the validation host: the Gallery advanced to 2.4x while
# git tags froze at v2.9.x. Every published version now gets a matching tag + release.
$tag = "v$version"
& git -C $root rev-parse -q --verify "refs/tags/$tag" *> $null
if ($LASTEXITCODE -eq 0) {
Write-Host "tag $tag already exists — skipping tag/release." -ForegroundColor Yellow
} else {
& git -C $root tag -a $tag -m "Guerrilla $version"
& git -C $root push origin $tag
Write-Host "tagged $tag and pushed" -ForegroundColor Green
if (Get-Command gh -ErrorAction SilentlyContinue) {
# This version's release-notes paragraph becomes the GitHub release body.
$notes = (Import-PowerShellDataFile (Join-Path $root 'source' 'Guerrilla.psd1')).PrivateData.PSData.ReleaseNotes
$body = ($notes -split '(?=v\d+\.\d+\.\d+:)' | Where-Object { $_ -like "v$version*" } | Select-Object -First 1)
if ([string]::IsNullOrWhiteSpace($body)) { $body = "Guerrilla $version — see CHANGELOG.md." }
Push-Location $root
try { $body | & gh release create $tag --title "Guerrilla $version" --notes-file - ; Write-Host "created GitHub release $tag" -ForegroundColor Green }
catch { Write-Host "gh release create failed ($_). Tag is pushed; create the release manually." -ForegroundColor Yellow }
finally { Pop-Location }
} else {
Write-Host "gh CLI not found — tag pushed, but create the GitHub release manually." -ForegroundColor Yellow
}
}