- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc. Fixes edited values staying recoverable by text extraction. - Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode. - Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon. - Remove the upstream author credit and the in-app install button.
631 lines
34 KiB
PowerShell
631 lines
34 KiB
PowerShell
#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
|
|
{
|
|
/// <summary>
|
|
/// Build-time constants written or verified by release.ps1.
|
|
/// </summary>
|
|
internal static class BuildInfo
|
|
{
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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 '<Version>([0-9]+\.[0-9]+\.[0-9]+)</Version>') {
|
|
throw "No <Version>x.y.z</Version> 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 <ReleaseDate> the preflight
|
|
# already checked against the CHANGELOG - Get-Date would stamp whatever day the script
|
|
# happened to run.
|
|
if ($csprojRaw -notmatch '<ReleaseDate>([0-9]{4}-[0-9]{2}-[0-9]{2})</ReleaseDate>') {
|
|
throw "No <ReleaseDate>yyyy-MM-dd</ReleaseDate> 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 '(<span class="k">version</span> <span class="v">)MmdPdf v[0-9]+\.[0-9]+\.[0-9]+', ('${1}' + "MmdPdf v$Version")
|
|
$indexNew = $indexNew -replace '(<span class="k">released</span> <span class="v">)[0-9]{4}-[0-9]{2}-[0-9]{2}', ('${1}' + $releaseDate)
|
|
$indexNew = $indexNew -replace '(<span class="k">size</span> <span class="v">)[^<]*', ('${1}' + "~$exeMB MB exe")
|
|
$indexNew = $indexNew -replace '(<span class="v hash">)[0-9A-Fa-f]{32}<br>[0-9A-Fa-f]{32}', ('${1}' + $hashLower.Substring(0, 32) + '<br>' + $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 <ReleaseDate> 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 '<ReleaseDate>([0-9]{4}-[0-9]{2}-[0-9]{2})</ReleaseDate>') {
|
|
throw "No <ReleaseDate>yyyy-MM-dd</ReleaseDate> 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 <ReleaseDate> 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
|
|
}
|