Initial docs, ported scripts from picknplay

This commit is contained in:
2026-02-24 21:15:43 +01:00
commit f3e3178f2f
8 changed files with 703 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
param(
[double]$MinLineRate = 0.90,
[double]$MinBranchRate = 0.70,
[string]$ResultsRoot = "RpgRoller.Tests/TestResults"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if (-not (Test-Path $ResultsRoot)) {
throw "Coverage results folder not found: $ResultsRoot"
}
$coverageFile = Get-ChildItem -Path $ResultsRoot -Recurse -Filter "coverage.cobertura.xml" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if ($null -eq $coverageFile) {
throw "No coverage.cobertura.xml found under $ResultsRoot"
}
[xml]$xml = Get-Content -Path $coverageFile.FullName
$coverage = $xml.coverage
if ($null -eq $coverage) {
throw "Coverage XML is missing root coverage node: $($coverageFile.FullName)"
}
[double]$lineRate = [double]$coverage.'line-rate'
[double]$branchRate = [double]$coverage.'branch-rate'
$linePercent = [Math]::Round($lineRate * 100, 2)
$branchPercent = [Math]::Round($branchRate * 100, 2)
$minLinePercent = [Math]::Round($MinLineRate * 100, 2)
$minBranchPercent = [Math]::Round($MinBranchRate * 100, 2)
Write-Host "Coverage source: $($coverageFile.FullName)"
Write-Host ("Line coverage: {0}% (required >= {1}%)" -f $linePercent, $minLinePercent)
Write-Host ("Branch coverage: {0}% (required >= {1}%)" -f $branchPercent, $minBranchPercent)
if ($lineRate -lt $MinLineRate -or $branchRate -lt $MinBranchRate) {
throw "Coverage thresholds failed."
}

75
scripts/ci-local.ps1 Normal file
View File

@@ -0,0 +1,75 @@
param(
[switch]$SkipNpmInstall,
[switch]$SkipDotnetRestore,
[switch]$SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Invoke-Step {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "==> $Name"
& $Action
if ($LASTEXITCODE -ne 0) {
throw "Step failed: $Name"
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
Push-Location $repoRoot
try {
if (-not $SkipNpmInstall) {
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
npm install
}
}
if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore RpgRoller.sln
}
}
if (-not $SkipBuild) {
Invoke-Step -Name "Build .NET solution (warnings as errors)" -Action {
dotnet build RpgRoller.sln --no-restore -warnaserror
}
}
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
npm run generate:api-client
}
Invoke-Step -Name "Lint frontend" -Action {
npm run lint
}
Invoke-Step -Name "Check frontend formatting" -Action {
npm run format:check
}
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
}
else {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
}
}
Invoke-Step -Name "Enforce coverage thresholds" -Action {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
}
Write-Host "CI checks passed."
}
finally {
Pop-Location
}

View File

@@ -0,0 +1,23 @@
@{
ProjectPath = "..\RpgRoller\RpgRoller.csproj"
Configuration = "Release"
Runtime = "win-x64"
PublishDir = "%TEMP%\RpgRoller-publish"
SelfContained = $false
WinScpPath = "C:\Users\frank\AppData\Local\Programs\WinSCP\WinSCP.com"
RemoteDir = "/httpdocs/rpgroller"
BasePath = "/rpgroller"
FtpHost = "xTr1m.com"
FtpUser = "xTr1m"
RecycleAppPool = $true
AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
WinRmComputer = "xTr1m.com"
WinRmCredentialUser = "Administrator"
UseWinRmHttps = $true
WinRmAuth = "Basic"
RunEfMigrations = $false
RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\rpgroller"
}

342
scripts/deploy-ftp.ps1 Normal file
View File

@@ -0,0 +1,342 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
<#
.SYNOPSIS
Publish the app and mirror output to an FTP-deployed IIS site.
.DESCRIPTION
- Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish.
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.EXAMPLE
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Tool {
param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool '$Name' not found. Install it or update your deploy profile."
}
}
function Require-ConfigValue {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$Key
)
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
throw "Missing required deploy profile value '$Key'."
}
}
function Resolve-ProfilePath {
param(
[Parameter(Mandatory = $true)][string]$BaseDirectory,
[Parameter(Mandatory = $true)][string]$PathValue
)
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
if ([System.IO.Path]::IsPathRooted($expanded)) {
return $expanded
}
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Normalize-BasePath {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ""
}
$normalized = $Value.Trim()
if (-not $normalized.StartsWith("/")) {
$normalized = "/$normalized"
}
if ($normalized.Length -gt 1) {
$normalized = $normalized.TrimEnd("/")
}
return $normalized
}
function Infer-BasePathFromRemoteDir {
param([string]$RemoteDir)
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
return ""
}
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($segments.Count -eq 0) {
return ""
}
$candidate = $segments[$segments.Count - 1]
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
return ""
}
return Normalize-BasePath $candidate
}
function Resolve-AppBasePath {
param([Parameter(Mandatory = $true)][hashtable]$Config)
if ($Config.ContainsKey("BasePath")) {
$configured = Normalize-BasePath ([string]$Config.BasePath)
if (-not [string]::IsNullOrWhiteSpace($configured)) {
return $configured
}
}
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
}
function Set-FrontendAppBaseMeta {
param(
[Parameter(Mandatory = $true)][string]$PublishDir,
[Parameter(Mandatory = $true)][string]$BasePath
)
$candidatePaths = @(
(Join-Path $PublishDir "wwwroot\index.html"),
(Join-Path $PublishDir "index.html")
)
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($indexPath)) {
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
}
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
$content = Get-Content -Path $indexPath -Raw
if ($content -notmatch $pattern) {
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
}
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
$updated = [System.Text.RegularExpressions.Regex]::Replace(
$content,
$pattern,
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
1
)
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
}
function Read-PlainOrPrompt {
param(
[string]$Value,
[Parameter(Mandatory = $true)][string]$Prompt,
[bool]$Secure = $false
)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
}
finally {
if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
}
}
return Read-Host -Prompt $Prompt
}
function Invoke-WinRmScript {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$PasswordValue,
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @()
)
Require-ConfigValue $Config "WinRmComputer"
Require-ConfigValue $Config "WinRmCredentialUser"
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
$invokeParams = @{
ComputerName = $Config.WinRmComputer
Credential = $cred
ScriptBlock = $ScriptBlock
ArgumentList = $ArgumentList
}
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
$invokeParams["UseSSL"] = $true
}
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
}
Invoke-Command @invokeParams
}
if (-not (Test-Path $ProfilePath)) {
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
}
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
$profileDirectory = Split-Path -Parent $resolvedProfilePath
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
Require-ConfigValue $config "ProjectPath"
Require-ConfigValue $config "Configuration"
Require-ConfigValue $config "Runtime"
Require-ConfigValue $config "PublishDir"
Require-ConfigValue $config "WinScpPath"
Require-ConfigValue $config "RemoteDir"
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
if (-not $useStoredSession) {
Require-ConfigValue $config "FtpHost"
Require-ConfigValue $config "FtpUser"
}
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
$needsFtpPassword = -not $useStoredSession
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
$sharedPassword = ""
if ($needsFtpPassword -or $needsWinRmPassword) {
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
}
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
Assert-Tool "dotnet"
Assert-Tool $winScpPath
Write-Host "1) Publishing..." -ForegroundColor Cyan
if (Test-Path $publishDir) {
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
if (-not $selfContained) {
$publishArgs += "--self-contained=false"
}
dotnet @publishArgs
$appBasePath = Resolve-AppBasePath -Config $config
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
$appPoolName = [string]$config.AppPoolName
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
}
}
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
$openCommand = if ($useStoredSession) {
"open `"$winScpSessionName`""
}
else {
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
$ftpHost = [string]$config.FtpHost
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
}
$tempScript = New-TemporaryFile
@(
"option batch continue"
"option confirm off"
$openCommand
"lcd `"$publishDir`""
"cd $([string]$config.RemoteDir)"
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
"exit"
) | Set-Content -Path $tempScript -Encoding UTF8
& $winScpPath "/ini=nul" "/script=$tempScript"
Remove-Item $tempScript -ErrorAction SilentlyContinue
if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Start-WebAppPool -Name $poolName
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)."
}
}
if ($runEfMigrations) {
Require-ConfigValue $config "RemoteSitePath"
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($sitePath)
Set-Location $sitePath
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet is not available on remote host."
}
dotnet ef database update --no-build
} -ArgumentList @([string]$config.RemoteSitePath)
}
catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
}
}
Write-Host "Done." -ForegroundColor Green

14
scripts/deploy-ftp1.ps1 Normal file
View File

@@ -0,0 +1,14 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
& $scriptPath `
-ProfilePath $ProfilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations