Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $siteRoot = $PSScriptRoot $configPath = $null $localArchive = $null $remoteArchive = $null $remoteArchiveMayExist = $false $deploymentSucceeded = $false $failureMessage = $null $locationPushed = $false $nativeCommands = @{} $sshOptions = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10") function Get-NativeCommandPath { param( [Parameter(Mandatory)] [string]$Name ) $command = Get-Command -Name $Name -CommandType Application -ErrorAction Stop return $command.Source } function Invoke-CheckedNativeCommand { param( [Parameter(Mandatory)] [string]$Command, [Parameter()] [string[]]$Arguments = @(), [Parameter(Mandatory)] [string]$Operation ) & $Command @Arguments $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "$Operation failed with exit code $exitCode." } } function Invoke-CheckedSshScript { param( [Parameter(Mandatory)] [string]$Command, [Parameter(Mandatory)] [string[]]$Options, [Parameter(Mandatory)] [string]$Target, [Parameter(Mandatory)] [string]$Script ) $Script | & $Command @Options $Target "sh -s" $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "Remote release activation failed with exit code $exitCode." } } try { if ($args.Count -gt 0) { throw "deploy.ps1 does not accept arguments. Edit deploy.config.psd1 instead." } if ([string]::IsNullOrWhiteSpace($siteRoot)) { throw "Unable to determine the site root. Run deploy.ps1 from a saved file." } $siteRoot = (Resolve-Path -LiteralPath $siteRoot).Path if (-not (Test-Path -LiteralPath (Join-Path $siteRoot "zola.toml") -PathType Leaf)) { throw "zola.toml was not found next to deploy.ps1." } $configPath = Join-Path $siteRoot "deploy.config.psd1" if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { throw ( "deploy.config.psd1 was not found. " + "Copy deploy.config.example.psd1 and enter the local deployment settings." ) } $deployConfig = Import-PowerShellDataFile -LiteralPath $configPath if ($deployConfig -isnot [System.Collections.IDictionary]) { throw "deploy.config.psd1 must contain a PowerShell data hashtable." } $requiredConfigKeys = @( "DeployHost", "DeployUser", "RemoteBase", "IdentityFile", "SiteUrl" ) foreach ($key in $requiredConfigKeys) { if (-not $deployConfig.Contains($key)) { throw "deploy.config.psd1 is missing the required key: $key" } } $DeployHost = ([string]$deployConfig["DeployHost"]).Trim() $DeployUser = ([string]$deployConfig["DeployUser"]).Trim() $RemoteBase = ([string]$deployConfig["RemoteBase"]).Trim() $IdentityFile = ([string]$deployConfig["IdentityFile"]).Trim() $siteUrl = ([string]$deployConfig["SiteUrl"]).Trim() if ($DeployHost -notmatch '^[A-Za-z0-9._-]+$') { throw "DeployHost contains unsupported characters." } if ($DeployUser -notmatch '^[A-Za-z_][A-Za-z0-9_-]*$') { throw "DeployUser contains unsupported characters." } $RemoteBase = $RemoteBase.TrimEnd('/') if ( $RemoteBase.Length -le 1 -or $RemoteBase -notmatch '^/[A-Za-z0-9._/-]+$' -or $RemoteBase -match '(^|/)\.\.?(/|$)' -or $RemoteBase.Contains('//') ) { throw "RemoteBase must be a normalized absolute Unix path other than '/'." } if (-not $RemoteBase.StartsWith('/srv/www/', [System.StringComparison]::Ordinal)) { throw "RemoteBase must be located below /srv/www/." } $parsedSiteUrl = $null if ( -not [System.Uri]::TryCreate( $siteUrl, [System.UriKind]::Absolute, [ref]$parsedSiteUrl ) -or $parsedSiteUrl.Scheme -ne "https" ) { throw "SiteUrl must be an absolute HTTPS URL." } if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) { $identityCandidate = $IdentityFile if (-not [System.IO.Path]::IsPathRooted($identityCandidate)) { $identityCandidate = Join-Path $siteRoot $identityCandidate } $identityPath = (Resolve-Path -LiteralPath $identityCandidate -ErrorAction Stop).Path if (-not (Test-Path -LiteralPath $identityPath -PathType Leaf)) { throw "IdentityFile must point to a file." } $sshOptions += @("-i", $identityPath) } foreach ($name in @("zola", "tar", "ssh", "scp")) { $nativeCommands[$name] = Get-NativeCommandPath -Name $name } Push-Location -LiteralPath $siteRoot $locationPushed = $true Write-Host "[1/6] Checking site..." Invoke-CheckedNativeCommand ` -Command $nativeCommands["zola"] ` -Arguments @("check") ` -Operation "zola check" Write-Host "[2/6] Building site..." Invoke-CheckedNativeCommand ` -Command $nativeCommands["zola"] ` -Arguments @("build") ` -Operation "zola build" $publicDir = Join-Path $siteRoot "public" $publicIndex = Join-Path $publicDir "index.html" if (-not (Test-Path -LiteralPath $publicIndex -PathType Leaf)) { throw "Build validation failed: public/index.html was not generated." } $resolvedPublicDir = (Resolve-Path -LiteralPath $publicDir).Path $siteRootPrefix = $siteRoot.TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) + [System.IO.Path]::DirectorySeparatorChar if (-not $resolvedPublicDir.StartsWith( $siteRootPrefix, [System.StringComparison]::OrdinalIgnoreCase )) { throw "Resolved public directory is outside the site root." } $releaseId = Get-Date -Format "yyyyMMdd-HHmmss" $archiveNonce = [System.Guid]::NewGuid().ToString("N") $archiveName = "takashi-2521-$releaseId-$archiveNonce.tar.gz" $localArchive = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName $remoteArchive = "/tmp/$archiveName" $remoteTarget = "$DeployUser@$DeployHost" Write-Host "[3/6] Creating archive..." Invoke-CheckedNativeCommand ` -Command $nativeCommands["tar"] ` -Arguments @("-czf", $localArchive, "-C", $resolvedPublicDir, ".") ` -Operation "tar archive creation" if ( -not (Test-Path -LiteralPath $localArchive -PathType Leaf) -or (Get-Item -LiteralPath $localArchive).Length -le 0 ) { throw "Archive validation failed: the local archive is missing or empty." } Write-Host "[4/6] Uploading release..." $scpArguments = @($sshOptions) + @( $localArchive, "${remoteTarget}:$remoteArchive" ) $remoteArchiveMayExist = $true Invoke-CheckedNativeCommand ` -Command $nativeCommands["scp"] ` -Arguments $scpArguments ` -Operation "SCP upload" $remoteScript = @' set -eu umask 022 base='__REMOTE_BASE__' release_id='__RELEASE_ID__' archive='__REMOTE_ARCHIVE__' release="$base/releases/$release_id" new_link="$base/current.new" release_created=0 cleanup() { status=$? trap - 0 rm -f -- "$archive" || true rm -f -- "$new_link" || true if [ "$status" -ne 0 ] && [ "$release_created" -eq 1 ]; then rm -rf -- "$release" || true fi exit "$status" } trap cleanup 0 test -d "$base/releases" test -L "$base/current" test -f "$base/current/index.html" test -f "$archive" if [ -e "$release" ] || [ -L "$release" ]; then echo "Release already exists: $release_id" >&2 exit 1 fi if [ -e "$new_link" ] || [ -L "$new_link" ]; then rm -f -- "$new_link" fi mkdir -- "$release" release_created=1 tar -xzf "$archive" -C "$release" test -f "$release/index.html" ln -s "releases/$release_id" "$new_link" mv -Tf "$new_link" "$base/current" release_created=0 '@ $remoteScript = $remoteScript.Replace("__REMOTE_BASE__", $RemoteBase) $remoteScript = $remoteScript.Replace("__RELEASE_ID__", $releaseId) $remoteScript = $remoteScript.Replace("__REMOTE_ARCHIVE__", $remoteArchive) Write-Host "[5/6] Switching release..." Invoke-CheckedSshScript ` -Command $nativeCommands["ssh"] ` -Options $sshOptions ` -Target $remoteTarget ` -Script $remoteScript $deploymentSucceeded = $true } catch { $failureMessage = $_.Exception.Message } finally { if ($locationPushed) { Pop-Location } if ($null -ne $localArchive -and (Test-Path -LiteralPath $localArchive)) { Remove-Item -LiteralPath $localArchive -Force -ErrorAction SilentlyContinue } if ( $remoteArchiveMayExist -and -not $deploymentSucceeded -and $null -ne $remoteArchive ) { $cleanupCommand = "rm -f -- '$remoteArchive'" & $nativeCommands["ssh"] @sshOptions $remoteTarget $cleanupCommand 2>$null | Out-Null } } if (-not $deploymentSucceeded) { [System.Console]::Error.WriteLine("Deployment failed: $failureMessage") exit 1 } Write-Host "[6/6] Done." Write-Host "" Write-Host "Release: $releaseId" Write-Host "URL: $siteUrl" exit 0