<# .SYNOPSIS Packages this repository and deploys it to IIS over Tailscale/OpenSSH. .DESCRIPTION This is the controller/CI entry point. It creates a ZIP containing the full repository (excluding VCS and local deployment artifacts), computes SHA-256, copies the package and host installer with scp, then invokes the installer on the Windows host through ssh. -DryRun performs local validation and prints the remote operations without connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI worker, a scheduled task, or an operator workstation. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_. -]+$')] [string]$SiteName, [string]$RemoteTarget = 'webserver-1', [ValidateRange(1, 65535)] [int]$RemotePort = 22, [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), [string]$DeployRoot = '', [string]$BaseUrl = '', [string]$ReleaseId = '', [string]$ExpectedBranch = 'master', [ValidateRange(2, 100)] [int]$KeepReleases = 5, [string]$SshExe = 'ssh', [string]$ScpExe = 'scp', [switch]$AllowAnyBranch, [switch]$AllowDirty, [switch]$RunMigrations, [switch]$SkipSmokeTest, [switch]$RemotePreflightOnly, [switch]$Rollback, [string]$RollbackTo = '', [switch]$DryRun, [switch]$KeepPackage ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Assert-Command { param([string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "$Name was not found on PATH." } } function ConvertTo-SingleQuotedPowerShell { param([string]$Value) return "'" + $Value.Replace("'", "''") + "'" } function Add-RemoteArgument { param( [System.Collections.Generic.List[string]]$Arguments, [string]$Name, [string]$Value ) $Arguments.Add($Name) $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) } function Copy-ReleaseSource { param( [string]$From, [string]$To ) New-Item -ItemType Directory -Force -Path $To | Out-Null $excludedNames = @('.git', '.deployment', 'releases') Get-ChildItem -LiteralPath $From -Force | ForEach-Object { if ($excludedNames -notcontains $_.Name) { Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force } } } $SourcePath = [System.IO.Path]::GetFullPath($SourcePath) $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { throw "Host installer is missing: $installerPath" } $requiredSourceFiles = @( 'public\Default.asp', 'public\web.config', 'core\autoload_core.asp', 'app\controllers\autoload_controllers.asp' ) foreach ($relativePath in $requiredSourceFiles) { if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { throw "Source tree is incomplete; missing $relativePath" } } try { [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null } catch { throw "Source public\web.config is not valid XML: $($_.Exception.Message)" } $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') $commit = 'nogit' if ($directGitRoot) { Assert-Command 'git' $branch = (& git -C $SourcePath branch --show-current).Trim() if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." } $dirty = & git -C $SourcePath status --porcelain if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' } $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } Write-Host "Source branch: $branch" Write-Host "Source commit: $commit" } else { Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' if (-not $AllowAnyBranch) { throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' } } if ([string]::IsNullOrWhiteSpace($ReleaseId)) { $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit } if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' } if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo .' } if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo is only valid with -Rollback.' } if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' } $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) $packageStage = Join-Path $workRoot 'package' $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' try { if (-not $Rollback -and -not $RemotePreflightOnly) { Write-Step 'Staging the full repository for packaging' Copy-ReleaseSource -From $SourcePath -To $packageStage Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::CreateFromDirectory( $packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false ) $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash Write-Host "Package: $packagePath" Write-Host "SHA-256: $sha256" } else { $sha256 = '' } $remoteArguments = New-Object 'System.Collections.Generic.List[string]' $remoteArguments.Add('&') $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName if ($Rollback) { Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo } elseif (-not $RemotePreflightOnly) { Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 } else { $remoteArguments.Add('-PreflightOnly') } if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot } if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl } Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } $remoteScript = $remoteArguments -join ' ' $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" if ($DryRun) { Write-Step 'Dry-run complete; no network connection was made' Write-Host "Would create remote directory: $remoteDirectory" Write-Host "Would copy installer: $installerPath" if (-not $Rollback -and -not $RemotePreflightOnly) { Write-Host "Would copy package: $packagePath" } Write-Host ('Would execute host script: ' + $remoteScript) Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') exit 0 } Assert-Command $SshExe Assert-Command $ScpExe Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } Write-Step 'Copying the host installer' & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } if (-not $Rollback -and -not $RemotePreflightOnly) { Write-Step 'Copying the release package' & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } } Write-Step 'Invoking the host-side installer' & $SshExe -p $RemotePort $RemoteTarget $remoteCommand if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } Write-Step 'Remote operation completed successfully' } finally { if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { $keptPath = Join-Path (Get-Location) ([System.IO.Path]::GetFileName($packagePath)) Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force Write-Host "Package retained at $keptPath" } if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } }