#Requires -Version 5.1
<#
.SYNOPSIS
MmdPdf release script: build payload → sign inner app → pack launcher → sign launcher → verify → publish.
.DESCRIPTION
1. Locates pdfium.dll in the NuGet cache, hashes it, and writes BuildInfo.cs so the
legacy woven development build's embedded integrity check knows the expected value.
2. Builds the ordinary multi-file MmdPdf.App payload without Costura/Fody weaving.
3. Signs MmdPdf.App.exe, regenerates its hash manifest, compresses that payload once,
and embeds it in the public portable/installer MmdPdf.exe.
4. Signs MmdPdf.exe. Prefers CertThumbprint (exact match) over CertName (CN match)
and retries the timestamp across three TSA endpoints.
5. Runs "signtool verify /pa /v" as a post-sign gate - aborts if either signature chain
is not trusted to an accepted root.
6. Builds the GPL source archive, checksums, and publish summary.
.PARAMETER CertThumbprint
Preferred. SHA1 thumbprint of your code-signing certificate (40 hex chars, no spaces).
Run: Get-ChildItem Cert:\CurrentUser\My | Select Thumbprint, Subject
Omit if using CertName instead.
.PARAMETER CertName
Fallback. CN (Subject) of your certificate as it appears in the Windows cert store.
Ignored when CertThumbprint is supplied.
.PARAMETER SkipSign
Skip signing. Writes all-zeros into BuildInfo.cs (disables runtime pdfium check).
Useful for local test builds. Prints a red warning banner.
.EXAMPLE
.\release.ps1 -CertThumbprint "AABBCC..."
.EXAMPLE
.\release.ps1 -CertName "Open Source Developer, Stephen Riley"
.EXAMPLE
.\release.ps1 -SkipSign
#>
param(
[string]$CertThumbprint = "",
[string]$CertName = "Open Source Developer Stephen Riley",
[switch]$SkipSign,
# Everything except tag push and GitHub release creation.
[switch]$DryRun,
# Skip build + sign and publish the artifacts already in the publish folder
# (use after a completed normal run, so the signed exe is not rebuilt).
[switch]$PublishOnly
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$proj = Join-Path $PSScriptRoot "MmdPdf.csproj"
$buildInfoPath = Join-Path $PSScriptRoot "BuildInfo.cs"
$publishDir = Join-Path $PSScriptRoot "bin\Release\net48\publish"
$exe = Join-Path $publishDir "MmdPdf.exe"
$portableBuild = Join-Path $PSScriptRoot "build\build-portable.ps1"
$payloadDir = Join-Path $PSScriptRoot "bin\Release\net48\portable-package\payload"
$innerExe = Join-Path $payloadDir "MmdPdf.App.exe"
# TSA endpoints - tried in order; first success wins.
$tsaList = @(
"http://timestamp.digicert.com",
"http://timestamp.sectigo.com",
"http://ts.ssl.com"
)
# Resolve the repo's default branch instead of hardcoding it, so the same script works across
# the Killer family. origin/HEAD is the best hint but it can go stale - it keeps naming a
# branch that was renamed away, which is exactly the state this repo was in - so a candidate
# is only accepted if it still exists on the remote. Order: origin/HEAD, then main, then master.
# Call it from inside the Push-Location block so git runs against this repo.
function Get-DefaultBranch {
$remoteHeads = @(git ls-remote --heads origin 2>$null) |
ForEach-Object { ($_ -split '\s+')[-1] -replace '^refs/heads/', '' }
if (-not $remoteHeads) { return $null }
$candidates = @()
$originHead = git symbolic-ref --quiet refs/remotes/origin/HEAD 2>$null
if ($originHead) { $candidates += (($originHead -replace '^refs/remotes/origin/', '').Trim()) }
foreach ($c in @('main', 'master')) { if ($candidates -notcontains $c) { $candidates += $c } }
foreach ($c in $candidates) {
if ($c -and $remoteHeads -contains $c) { return $c }
}
return $null
}
if (-not $PublishOnly) {
# ── 0. SimplySign preflight ──────────────────────────────────────────────────
if (-not $SkipSign) {
$ssProc = Get-Process -Name "SimplySignDesktop" -ErrorAction SilentlyContinue
if (-not $ssProc) {
Write-Host ""
Write-Warning "SimplySign Desktop does not appear to be running."
Write-Host " Start it and wait for it to show 'Connected', then press Enter to continue."
Write-Host " Or press Ctrl+C to abort."
$null = Read-Host
} else {
Write-Host "`n==> SimplySign Desktop is running (PID $($ssProc.Id))." -ForegroundColor Green
}
}
# ── 0. Translation parity ───────────────────────────────────────────────────
# Every localization must carry the complete English key set, and the placeholders have to match:
# a translation loads perfectly and still throws at runtime when string.Format is handed a value
# the translation dropped or renumbered. Ported from KillerNotes, which took it from Killendar.
#
# First, because it is a pure source check and costs nothing. Without it this tree silently drifted
# to ten locales missing the same 20 keys - the whole crash dialog among them - and a pl-PL key that
# does not exist in English. Nothing reported it until users did.
Write-Host "`n==> Checking translations..." -ForegroundColor Cyan
function Read-StringMap([string]$Path) {
[xml]$document = Get-Content -Path $Path -Raw
$map = @{}
foreach ($node in $document.ResourceDictionary.ChildNodes) {
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$key = $node.GetAttribute('Key', 'http://schemas.microsoft.com/winfx/2006/xaml')
if ($key) { $map[$key] = [string]$node.InnerText }
}
return $map
}
$stringsDir = Join-Path $PSScriptRoot 'Strings'
$englishStrings = Read-StringMap (Join-Path $stringsDir 'en-US.xaml')
if ($englishStrings.Count -eq 0) { throw "English translation file contains no resource keys." }
foreach ($localeFile in Get-ChildItem $stringsDir -Filter '*.xaml') {
if ($localeFile.Name -eq 'en-US.xaml') { continue }
$localized = Read-StringMap $localeFile.FullName
$missing = @($englishStrings.Keys | Where-Object { -not $localized.ContainsKey($_) })
$extra = @($localized.Keys | Where-Object { -not $englishStrings.ContainsKey($_) })
$empty = @($localized.Keys | Where-Object { [string]::IsNullOrWhiteSpace($localized[$_]) })
$placeholderMismatch = @()
foreach ($key in $englishStrings.Keys) {
if (-not $localized.ContainsKey($key)) { continue }
$englishPlaceholders = @([regex]::Matches($englishStrings[$key], '\{\d+(?::[^}]*)?\}') |
ForEach-Object Value | Sort-Object)
$localizedPlaceholders = @([regex]::Matches($localized[$key], '\{\d+(?::[^}]*)?\}') |
ForEach-Object Value | Sort-Object)
if ([string]::Join('|', $englishPlaceholders) -ne
[string]::Join('|', $localizedPlaceholders)) {
$placeholderMismatch += $key
}
}
if ($missing.Count -or $extra.Count -or $empty.Count -or $placeholderMismatch.Count) {
if ($missing.Count) { Write-Host " missing: $($missing -join ', ')" -ForegroundColor Yellow }
if ($extra.Count) { Write-Host " extra: $($extra -join ', ')" -ForegroundColor Yellow }
if ($empty.Count) { Write-Host " empty: $($empty -join ', ')" -ForegroundColor Yellow }
if ($placeholderMismatch.Count) { Write-Host " placeholders: $($placeholderMismatch -join ', ')" -ForegroundColor Yellow }
throw "$($localeFile.Name) is incomplete: missing=$($missing.Count), extra=$($extra.Count), empty=$($empty.Count), placeholder mismatches=$($placeholderMismatch.Count)"
}
}
Write-Host " Translations OK: $($englishStrings.Count) keys across $((Get-ChildItem $stringsDir -Filter '*.xaml').Count) languages" -ForegroundColor Green
# ── 1. Hash pdfium.dll and update BuildInfo.cs ──────────────────────────────
Write-Host "`n==> Locating pdfium.dll for integrity pre-hash..." -ForegroundColor Cyan
# Look in the NuGet package cache for Docnet.Core's pdfium
$nugetCache = Join-Path $env:USERPROFILE ".nuget\packages"
$pdfiumNuget = Get-ChildItem "$nugetCache\docnet.core\*\runtimes\win-x64\native\pdfium.dll" `
-ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
# Also check the build output as a fallback
$pdfiumBuild = Join-Path $PSScriptRoot "bin\Release\net48\win-x64\pdfium.dll"
$pdfiumPath = $null
if ($pdfiumNuget -and (Test-Path $pdfiumNuget)) {
$pdfiumPath = $pdfiumNuget
Write-Host " Using NuGet cache: $pdfiumPath"
} elseif (Test-Path $pdfiumBuild) {
$pdfiumPath = $pdfiumBuild
Write-Host " Using build output: $pdfiumPath"
} else {
Write-Warning " pdfium.dll not found - BuildInfo.cs will retain all-zeros (check disabled)."
}
$pdfiumHash = "0000000000000000000000000000000000000000000000000000000000000000"
if ($pdfiumPath) {
$pdfiumHash = (Get-FileHash $pdfiumPath -Algorithm SHA256).Hash
Write-Host " pdfium SHA256: $pdfiumHash" -ForegroundColor Green
}
if ($SkipSign) {
# Leave all-zeros so the runtime check is disabled
$pdfiumHash = "0000000000000000000000000000000000000000000000000000000000000000"
Write-Host " SkipSign: BuildInfo.cs will keep all-zeros (check disabled)." -ForegroundColor Yellow
}
Write-Host "`n==> Writing BuildInfo.cs..." -ForegroundColor Cyan
$buildInfoContent = @"
namespace MmdPdf
{
///
/// Build-time constants written or verified by release.ps1.
///
internal static class BuildInfo
{
///
/// SHA256 of pdfium.dll (original bytes, before Costura compression).
/// Updated by release.ps1 immediately before each build.
/// All-zeros means the check is disabled (dev / SkipSign builds).
///
internal const string PdfiumSha256 = "$pdfiumHash";
internal const string PdfiumSha256Disabled = "0000000000000000000000000000000000000000000000000000000000000000";
}
}
"@
$buildInfoContent = $buildInfoContent.TrimEnd("`r", "`n") + [Environment]::NewLine
$currentBuildInfo = if (Test-Path $buildInfoPath) { [System.IO.File]::ReadAllText($buildInfoPath) } else { "" }
$normalizedBuildInfo = $buildInfoContent -replace "`r`n?", "`n"
$normalizedCurrentBuildInfo = $currentBuildInfo -replace "`r`n?", "`n"
if ($normalizedCurrentBuildInfo -ne $normalizedBuildInfo) {
[System.IO.File]::WriteAllText($buildInfoPath, $buildInfoContent, [System.Text.UTF8Encoding]::new($false))
Write-Host " BuildInfo.cs updated." -ForegroundColor Green
} else {
Write-Host " BuildInfo.cs already current." -ForegroundColor Green
}
# ── 2. Build the loose payload and one-payload launcher ──────────────────────
Write-Host "`n==> Building loose payload + portable launcher..." -ForegroundColor Cyan
& powershell -NoProfile -ExecutionPolicy Bypass -File $portableBuild -RequireSignature
if ($LASTEXITCODE -ne 0) { throw "Portable package build failed." }
if (-not (Test-Path $exe)) { throw "EXE not found at: $exe" }
if (-not (Test-Path $innerExe)) { throw "Inner application not found at: $innerExe" }
Write-Host " EXE: $exe" -ForegroundColor Green
# ── 3. Sign ─────────────────────────────────────────────────────────────────
if (-not $SkipSign) {
Write-Host "`n==> Locating signtool..." -ForegroundColor Cyan
$signtool = $null
$kitBase = "${env:ProgramFiles(x86)}\Windows Kits\10\bin"
if (Test-Path $kitBase) {
$signtool = Get-ChildItem "$kitBase\*\x64\signtool.exe" -Recurse -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $signtool) { throw "signtool.exe not found. Install the Windows SDK." }
Write-Host " $signtool"
# Build cert selector args
$certArgs = if ($CertThumbprint) {
Write-Host "`n==> Signing with thumbprint $CertThumbprint..." -ForegroundColor Cyan
@("/sha1", $CertThumbprint)
} else {
Write-Host "`n==> Signing with CN: $CertName..." -ForegroundColor Cyan
@("/n", $CertName)
}
# Sign the real installed application before it is compressed into the public launcher.
# Third-party binaries retain their publishers' signatures; only Killer-owned binaries are signed here.
Write-Host "`n==> Signing inner application before payload packaging..." -ForegroundColor Cyan
$innerSigned = $false
foreach ($tsa in $tsaList) {
& $signtool sign /fd sha256 /tr $tsa /td sha256 @certArgs `
/d "MmdPdf Application" /du "https://baobab-ts.com" /v $innerExe
if ($LASTEXITCODE -eq 0) { $innerSigned = $true; break }
Start-Sleep -Seconds 3
}
if (-not $innerSigned) { throw "Signing the inner application failed on all TSA endpoints." }
& $signtool verify /pa /v $innerExe
if ($LASTEXITCODE -ne 0) { throw "Inner application signature verification failed." }
# Signing changes the inner EXE hash. Rebuild the manifest, compressed payload, and outer
# launcher so the payload contains the signed bytes and verifies them after extraction.
Write-Host "`n==> Repacking signed payload..." -ForegroundColor Cyan
& powershell -NoProfile -ExecutionPolicy Bypass -File $portableBuild -RepackOnly -RequireSignature
if ($LASTEXITCODE -ne 0) { throw "Signed payload repack failed." }
# Timestamp with retry across TSA list
$signed = $false
foreach ($tsa in $tsaList) {
Write-Host " Trying TSA: $tsa"
& $signtool sign `
/fd sha256 `
/tr $tsa `
/td sha256 `
@certArgs `
/d "MmdPdf" `
/du "https://baobab-ts.com" `
/v $exe
if ($LASTEXITCODE -eq 0) {
Write-Host " Signed and timestamped via $tsa" -ForegroundColor Green
$signed = $true
break
}
Write-Warning " TSA $tsa failed (exit $LASTEXITCODE). Trying next..."
Start-Sleep -Seconds 3
}
if (-not $signed) { throw "Signing failed on all TSA endpoints. Is SimplySign Desktop connected?" }
# ── Post-sign verification gate ─────────────────────────────────────────
Write-Host "`n==> Verifying signature chain (/pa)..." -ForegroundColor Cyan
& $signtool verify /pa /v $exe
if ($LASTEXITCODE -ne 0) {
throw "signtool verify FAILED. The signed EXE does not pass trust validation. DO NOT RELEASE."
}
Write-Host " Signature chain OK." -ForegroundColor Green
# Print the thumbprint of the cert that was actually used
try {
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($exe)
$cert2 = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert)
$actualThumb = $cert2.Thumbprint
$actualCN = $cert2.GetNameInfo(
[System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)
Write-Host " Signer : $actualCN" -ForegroundColor Green
Write-Host " Thumbprint: $actualThumb" -ForegroundColor Green
} catch {
Write-Warning " Could not read signer info from signed EXE: $_"
$actualThumb = "(unknown)"
$actualCN = "(unknown)"
}
} else {
Write-Host ""
Write-Host " #####################################################" -ForegroundColor Red
Write-Host " ## WARNING: -SkipSign is set. EXE IS NOT SIGNED. ##" -ForegroundColor Red
Write-Host " ## DO NOT DISTRIBUTE this build as a release. ##" -ForegroundColor Red
Write-Host " #####################################################" -ForegroundColor Red
$actualThumb = "(not signed)"
$actualCN = "(not signed)"
}
# The payload build deliberately suppresses the app project's AfterPublish source target.
# Generate the GPL source artifact once, beside the final public launcher.
$projectXml = [xml](Get-Content -Raw -LiteralPath $proj)
$releaseVersionNode = $projectXml.SelectSingleNode('/Project/PropertyGroup/Version')
if (-not $releaseVersionNode) { throw "Version missing from MmdPdf.csproj." }
$sourceBundleScript = Join-Path $PSScriptRoot 'build\bundle-source.ps1'
$releaseBuildVersion = $releaseVersionNode.InnerText
& powershell -NoProfile -ExecutionPolicy Bypass -File $sourceBundleScript `
-ProjectDir $PSScriptRoot -Version $releaseBuildVersion -AppName 'MmdPdf' -PublishDir $publishDir
if ($LASTEXITCODE -ne 0) { throw "Source bundle failed." }
} else {
# PublishOnly: the artifacts from the last full run are the release.
Write-Host "`n==> PublishOnly: skipping build and sign, using existing artifacts." -ForegroundColor Yellow
if (-not (Test-Path $exe)) { throw "PublishOnly: no built exe at $exe - run the full script first." }
$pdfiumPath = $null
$pdfiumHash = ""
$actualThumb = "(existing signature)"
$actualCN = "(existing signature)"
}
# ── 4. SHA256 (final EXE) ─────────────────────────────────────────────────
Write-Host "`n==> Computing final EXE SHA256..." -ForegroundColor Cyan
$exeHash = (Get-FileHash $exe -Algorithm SHA256).Hash
Write-Host " MmdPdf.exe : $exeHash" -ForegroundColor Green
if ($pdfiumPath) {
Write-Host " pdfium.dll : $pdfiumHash" -ForegroundColor Green
}
# ── 5. Source zip ────────────────────────────────────────────────────────────
$srcZip = Get-ChildItem $publishDir -Filter "*-src.zip" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($srcZip) {
Write-Host "`n==> Source zip: $($srcZip.FullName)" -ForegroundColor Green
} else {
Write-Host "`n (No source zip found - did bundle-source.ps1 run?)" -ForegroundColor Yellow
}
# ── 6. Write SHA256SUMS.txt ──────────────────────────────────────────────────
# Written into the publish folder next to MmdPdf.exe and the -src.zip, so every file you
# upload to the GitHub release is in one place. The updater reads this from the release assets.
$sumsPath = Join-Path $publishDir "SHA256SUMS.txt"
if ($PublishOnly -and (Test-Path $sumsPath)) {
# Keep the full-run file: rewriting here would drop the pdfium line (not recomputed).
Write-Host "`n==> PublishOnly: keeping existing SHA256SUMS.txt." -ForegroundColor Yellow
} else {
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("MmdPdf.exe $exeHash")
if ($pdfiumPath) { $lines.Add("pdfium.dll $pdfiumHash") }
if ($srcZip) {
$srcHash = (Get-FileHash $srcZip.FullName -Algorithm SHA256).Hash
$lines.Add("$($srcZip.Name.PadRight(24))$srcHash")
}
[System.IO.File]::WriteAllLines($sumsPath, $lines, [System.Text.UTF8Encoding]::new($false))
Write-Host "`n==> SHA256SUMS.txt written to: $sumsPath" -ForegroundColor Green
}
# ── 7. Summary ───────────────────────────────────────────────────────────────
Write-Host "`n╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host " MmdPdf release artifacts" -ForegroundColor White
Write-Host " EXE : $exe"
if ($srcZip) { Write-Host " SRC : $($srcZip.FullName)" }
Write-Host ""
Write-Host " SHA256 (EXE) : $exeHash" -ForegroundColor Green
if ($pdfiumPath) {
Write-Host " SHA256 (pdfium.dll): $pdfiumHash" -ForegroundColor Green }
Write-Host ""
Write-Host " Signer : $actualCN"
Write-Host " Thumbprint: $actualThumb"
Write-Host ""
Write-Host " pdf-landing's hero (version/date/size/sha256) is updated automatically"
Write-Host " in the publish preflight below - no hand-pasting."
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
# ============================================================================
# Publish phases (ported from the KillerNotes release script): notes from the
# CHANGELOG, git preflight, tag + push, and GitHub release. Publishing the release triggers
# .github/workflows/winget-release.yml, the single WinGet submission path.
# ============================================================================
# ── 8. Version + publish preflight ───────────────────────────────────────────
Write-Host "`n==> Publish preflight..." -ForegroundColor Cyan
$csprojRaw = Get-Content -Path $proj -Raw
if ($csprojRaw -notmatch '([0-9]+\.[0-9]+\.[0-9]+)') {
throw "No x.y.z found in MmdPdf.csproj"
}
$Version = $Matches[1]
$Tag = "v$Version"
Write-Host " Version: $Version (tag $Tag)"
Push-Location $PSScriptRoot
try {
$defaultBranch = Get-DefaultBranch
if (-not $defaultBranch) { throw "Could not determine the default branch from origin" }
Write-Host " Default branch: $defaultBranch"
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
if ($branch -ne $defaultBranch) { throw "On branch '$branch', expected $defaultBranch" }
$dirty = git status --porcelain
if ($dirty) { throw "Working tree is not clean. Commit or stash first:`n$($dirty -join "`n")" }
# Keep the README's GPL3 source link pointed at the current release - it
# names the versioned -src.zip, so it goes stale on every version bump.
# ReadAllText/WriteAllText: UTF-8 no BOM, PS 5.1-safe (absolute paths).
$readmePath = Join-Path $PSScriptRoot 'README.md'
$readmeRaw = [System.IO.File]::ReadAllText($readmePath)
$readmeNew = $readmeRaw -replace 'releases/download/v[0-9]+\.[0-9]+\.[0-9]+/MmdPdf-[0-9]+\.[0-9]+\.[0-9]+-src\.zip', "releases/download/v$Version/MmdPdf-$Version-src.zip"
if ($readmeNew -ne $readmeRaw) {
if ($DryRun) {
Write-Host " DryRun: README source link is stale, would update it to $Tag" -ForegroundColor Yellow
} else {
Write-Host " Updating README source link to $Tag"
[System.IO.File]::WriteAllText($readmePath, $readmeNew)
git commit README.md -m "Point README source link at $Tag" --quiet
git push origin $defaultBranch --quiet
if ($LASTEXITCODE -ne 0) { throw "README source-link commit failed to push" }
}
}
# ── Landing page release info (pdf-landing) ──────────────────────────────
# Ported from Killendar's release.ps1 step 7 (the family standard - KillerNotes has it
# too; MmdPdf was the odd one out and its hero went stale by hand every release).
# baobab-ts.com is a MANUAL Cloudflare Pages drop, so nothing here deploys - the hero
# block (version, released, size, sha256), the verEgg footer on every page, and the ten
# translated footers in kp-i18n.js are rewritten and committed BEFORE the tag.
# Two site-specific differences from Killendar's copy: the hash is stored LOWERCASE
# here, and the size row carries a '~' prefix. ReadAllText/WriteAllText keep the files
# BOM-less UTF-8 (PS 5.1 Set-Content -Encoding UTF8 adds a BOM).
# ONE source of truth for the release date: the csproj the preflight
# already checked against the CHANGELOG - Get-Date would stamp whatever day the script
# happened to run.
if ($csprojRaw -notmatch '([0-9]{4}-[0-9]{2}-[0-9]{2})') {
throw "No yyyy-MM-dd found in MmdPdf.csproj"
}
$releaseDate = $Matches[1]
$hashLower = $exeHash.ToLower()
$exeMB = [math]::Round((Get-Item $exe).Length / 1MB, 2)
$siteDir = Join-Path $PSScriptRoot 'pdf-landing'
$indexPath = Join-Path $siteDir 'index.html'
$indexRaw = [System.IO.File]::ReadAllText($indexPath)
$indexNew = $indexRaw
$indexNew = $indexNew -replace '(version )MmdPdf v[0-9]+\.[0-9]+\.[0-9]+', ('${1}' + "MmdPdf v$Version")
$indexNew = $indexNew -replace '(released )[0-9]{4}-[0-9]{2}-[0-9]{2}', ('${1}' + $releaseDate)
$indexNew = $indexNew -replace '(size )[^<]*', ('${1}' + "~$exeMB MB exe")
$indexNew = $indexNew -replace '()[0-9A-Fa-f]{32}
[0-9A-Fa-f]{32}', ('${1}' + $hashLower.Substring(0, 32) + '
' + $hashLower.Substring(32, 32))
if ($indexNew -eq $indexRaw) {
Write-Warning 'index.html hero block did not change - check the release-info markup still matches the patterns in this script.'
}
if ($DryRun) {
Write-Host " DryRun: would write these release facts to pdf-landing and commit:" -ForegroundColor Yellow
Write-Host " version : MmdPdf v$Version"
Write-Host " released : $releaseDate"
Write-Host " size : ~$exeMB MB exe"
Write-Host " sha256 : $hashLower"
Write-Host " verEgg : v$Version on index, help, technical, about + kp-i18n.js"
} else {
if ($indexNew -ne $indexRaw) { [System.IO.File]::WriteAllText($indexPath, $indexNew) }
# Footer version on every page, plus the ten translated footer strings in kp-i18n.js
# (their verEgg span is spelled with escaped quotes there, hence the \\? in the
# pattern matching both id="verEgg" and id=\"verEgg\").
foreach ($page in 'index.html', 'help.html', 'technical.html', 'about.html', 'kp-i18n.js') {
$p = Join-Path $siteDir $page
if (-not (Test-Path $p)) { continue }
$raw = [System.IO.File]::ReadAllText($p)
$new = $raw -replace '(id=\\?"verEgg\\?"[^>]*>)v[0-9]+\.[0-9]+\.[0-9]+', ('${1}' + "v$Version")
if ($new -ne $raw) { [System.IO.File]::WriteAllText($p, $new) }
}
$siteDirty = git status --porcelain pdf-landing
if ($siteDirty) {
git add pdf-landing
git commit -m "v${Version}: landing release info" --quiet
git push origin $defaultBranch --quiet
if ($LASTEXITCODE -ne 0) { throw "Landing page commit failed to push" }
Write-Host " pdf-landing updated to v$Version and pushed"
Write-Host " Remember: baobab-ts.com does NOT auto-deploy. Drag pdf-landing/ into Cloudflare Pages." -ForegroundColor Yellow
} else {
Write-Host " pdf-landing already current"
}
# Facts the site states in PROSE, which no other gate can reach. Edit-SiteFact keeps
# version, size and hash honest, and OcrCatalogTests keeps the app's OCR list matching
# Strings\, but a sentence like "OCR supports ten languages" is just words in a paragraph.
# That one was wrong for two releases and nothing noticed, so the count is compared to the
# copy here. Silent when the page agrees; only speaks up on a mismatch.
$localeCount = @(Get-ChildItem (Join-Path $PSScriptRoot 'Strings') -Filter '*.xaml').Count
$numberWords = @{
'eight' = 8; 'nine' = 9; 'ten' = 10; 'eleven' = 11; 'twelve' = 12
'thirteen' = 13; 'fourteen' = 14; 'fifteen' = 15; 'sixteen' = 16
}
$sitePages = @('index.html', 'help.html', 'technical.html', 'about.html') |
ForEach-Object { Join-Path $PSScriptRoot "pdf-landing\$_" } | Where-Object { Test-Path $_ }
$claimMismatches = @()
foreach ($page in $sitePages) {
# Only sentences about INTERFACE or OCR languages. "fifteen languages" in a sentence
# about syntax highlighting is a different count and must not be flagged.
$hits = Select-String -Path $page -Pattern '(?i)\b(\w+)\s+(?:languages|locales)\b' -AllMatches
foreach ($hit in $hits) {
if ($hit.Line -notmatch '(?i)OCR|interface|localiz|locale|translated') { continue }
foreach ($m in $hit.Matches) {
$word = $m.Groups[1].Value
$claimed = if ($numberWords.ContainsKey($word.ToLower())) { $numberWords[$word.ToLower()] }
elseif ($word -match '^\d+$') { [int]$word } else { $null }
if ($null -ne $claimed -and $claimed -ne $localeCount) {
$claimMismatches += " $(Split-Path $page -Leaf):$($hit.LineNumber) says '$($m.Value)' but $localeCount locales ship"
}
}
}
}
if ($claimMismatches.Count) {
Write-Host ""
Write-Warning "Landing-page copy disagrees with the shipped locale count:"
$claimMismatches | Sort-Object -Unique | ForEach-Object { Write-Host $_ -ForegroundColor Yellow }
Write-Host " Fix the copy, or confirm the sentence is about something else." -ForegroundColor Yellow
}
}
git fetch origin $defaultBranch --quiet
if ((git rev-parse HEAD).Trim() -ne (git rev-parse "origin/$defaultBranch").Trim()) {
throw "Local $defaultBranch and origin/$defaultBranch differ. Push or pull first."
}
if (git tag --list $Tag) { throw "Tag $Tag already exists" }
if (git ls-remote --tags origin $Tag) { throw "Tag $Tag already exists on origin" }
$changelog = Get-Content -Path (Join-Path $PSScriptRoot 'CHANGELOG.md') -Raw
if ($changelog -match ('(?im)^## \[' + [regex]::Escape($Version) + '\] - UNRELEASED\s*$')) {
throw "CHANGELOG.md section [$Version] is still marked Unreleased"
}
if ($changelog -notmatch [regex]::Escape("## [$Version]")) {
throw "CHANGELOG.md has no [$Version] section"
}
# The About card shows beside the version so users can tell how old
# their build is. It is a hand-edited csproj field, so it silently goes stale unless
# something checks it - that something is here. It must equal the date on this
# version's CHANGELOG section, which is the date the release actually goes out.
if ($csprojRaw -notmatch '([0-9]{4}-[0-9]{2}-[0-9]{2})') {
throw "No yyyy-MM-dd found in MmdPdf.csproj"
}
$releaseDate = $Matches[1]
if ($changelog -notmatch ('## \[' + [regex]::Escape($Version) + '\] - ([0-9]{4}-[0-9]{2}-[0-9]{2})')) {
throw "CHANGELOG.md section [$Version] has no yyyy-MM-dd date"
}
$changelogDate = $Matches[1]
if ($releaseDate -ne $changelogDate) {
throw "csproj is $releaseDate but CHANGELOG [$Version] is dated $changelogDate. Bump the csproj."
}
Write-Host " Release date: $releaseDate"
# A red test cannot ship. Same gate as the date checks above - fail the release, not a reminder.
Write-Host " Running unit tests..."
dotnet test (Join-Path $PSScriptRoot 'MmdPdf.Tests\MmdPdf.Tests.csproj') -c Release --nologo -v quiet
if ($LASTEXITCODE -ne 0) { throw "Unit tests failed - fix them before releasing" }
Write-Host " Unit tests passed" -ForegroundColor Green
Write-Host " Preflight OK" -ForegroundColor Green
# ── 9. Release notes from the CHANGELOG section ──────────────────────────
Write-Host "`n==> Extracting release notes from CHANGELOG.md..." -ForegroundColor Cyan
$clLines = Get-Content -Path (Join-Path $PSScriptRoot 'CHANGELOG.md')
$notes = New-Object System.Collections.Generic.List[string]
$inSection = $false
foreach ($line in $clLines) {
if ($line -match "^## \[$([regex]::Escape($Version))\]") { $inSection = $true; continue }
if ($inSection -and $line -match '^## \[') { break }
if ($inSection) { $notes.Add($line) }
}
if ($notes.Count -eq 0) { throw "Could not extract [$Version] notes from CHANGELOG.md" }
$notesFile = Join-Path $env:TEMP "MmdPdf-$Version-notes.md"
$notes -join "`r`n" | Set-Content -Path $notesFile -Encoding UTF8
Write-Host " Notes written to $notesFile ($($notes.Count) lines)"
if ($DryRun) {
Write-Host "`n==> DryRun: stopping before tag and release." -ForegroundColor Yellow
Write-Host " Would tag $Tag, push it, and publish MmdPdf.exe, the -src.zip, and SHA256SUMS.txt."
exit 0
}
# ── 10. Tag + push ───────────────────────────────────────────────────────
Write-Host "`n==> Tagging $Tag..." -ForegroundColor Cyan
git tag -a $Tag -m "MmdPdf $Tag"
git push origin $Tag
if ($LASTEXITCODE -ne 0) { throw "Tag push failed" }
# ── 11. GitHub release ───────────────────────────────────────────────────
Write-Host "`n==> Creating GitHub release..." -ForegroundColor Cyan
$assets = @($exe)
if ($srcZip) { $assets += $srcZip.FullName }
if (Test-Path $sumsPath) { $assets += $sumsPath }
gh release create $Tag @assets --title "MmdPdf $Tag" --notes-file $notesFile --verify-tag
if ($LASTEXITCODE -ne 0) { throw "gh release create failed" }
Write-Host "`n==> Refreshing thekiller.net software page..." -ForegroundColor Cyan
gh workflow run deploy.yml --repo SteveTheKiller/thekiller-site
if ($LASTEXITCODE -ne 0) {
Write-Warning "The release is published, but thekiller.net refresh could not be started. Run: gh workflow run deploy.yml --repo SteveTheKiller/thekiller-site"
}
Write-Host "`n==> Release $Tag published:" -ForegroundColor Green
Write-Host " The WinGet Release workflow will submit this version once from GitHub Actions."
gh release view $Tag --json url --jq '.url'
} finally {
Pop-Location
}