|
- <#
- .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 <release-id>.'
- }
- 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
- }
- }
|