[CmdletBinding()] param( [string]$SourceDir = "server", [Parameter(Mandatory = $true)] [string]$RemoteDir, [string]$HostName = "sbnews", [string]$SshBin = "ssh.exe", [switch]$AutoIncremental, [string]$From = "", [string]$To = "", [string]$RemoteTmp = "/tmp", [string]$PostInstall = "", [switch]$Sudo, [switch]$KeepRemoteTemp, [switch]$IncludeVendor, [switch]$IncludeEnv, [switch]$IncludeRuntime, [switch]$IncludeUploads, [switch]$IncludePublicBuilds, [switch]$IncludeCrawlerData, [switch]$DryRun ) $ErrorActionPreference = "Stop" $Utf8NoBom = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = $Utf8NoBom $OutputEncoding = $Utf8NoBom $DeployWatch = [System.Diagnostics.Stopwatch]::StartNew() $script:LastLogMs = 0.0 function Format-Duration { param([Int64]$Milliseconds) $span = [TimeSpan]::FromMilliseconds([Math]::Max(0, $Milliseconds)) if ($span.TotalMinutes -ge 1) { return "{0}m{1:00}.{2:000}s" -f [Math]::Floor($span.TotalMinutes), $span.Seconds, $span.Milliseconds } return "{0}.{1:000}s" -f [Math]::Floor($span.TotalSeconds), $span.Milliseconds } function Write-DeployLog { param([string]$Message) $total = [Int64]$DeployWatch.ElapsedMilliseconds $step = $total - $script:LastLogMs $script:LastLogMs = $total Write-Host ("[deploy][+{0}][total {1}] {2}" -f (Format-Duration $step), (Format-Duration $total), $Message) } function Fail { param([string]$Message) throw "[deploy][error] $Message" } function Require-Command { param([string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { Fail "missing command: $Name" } } function Normalize-DeployDir { param([string]$Value) return $Value.TrimEnd("/", "\") } function Normalize-ComparePath { param([string]$Value) return ([System.IO.Path]::GetFullPath($Value).TrimEnd("\", "/") -replace "\\", "/").ToLowerInvariant() } function ConvertTo-RemoteSingleQuoted { param([string]$Value) return "'" + $Value.Replace("'", "'\''") + "'" } function Invoke-Native { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$Arguments = @() ) & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { Fail "command failed ($LASTEXITCODE): $FilePath $($Arguments -join ' ')" } } function Invoke-NativeOutput { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$Arguments = @() ) $output = & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { Fail "command failed ($LASTEXITCODE): $FilePath $($Arguments -join ' ')" } return $output } function ConvertTo-WindowsArgument { param([string]$Value) if ($null -eq $Value) { return '""' } if ($Value -notmatch '[\s"]') { return $Value } $result = '"' $slashes = 0 foreach ($ch in $Value.ToCharArray()) { if ($ch -eq '\') { $slashes++ continue } if ($ch -eq '"') { $result += ('\' * (($slashes * 2) + 1)) $result += '"' $slashes = 0 continue } if ($slashes -gt 0) { $result += ('\' * $slashes) $slashes = 0 } $result += $ch } if ($slashes -gt 0) { $result += ('\' * ($slashes * 2)) } $result += '"' return $result } function Set-ProcessArguments { param( [System.Diagnostics.ProcessStartInfo]$ProcessStartInfo, [string[]]$Arguments ) $argumentListProperty = $ProcessStartInfo.GetType().GetProperty("ArgumentList") $argumentList = $null if ($null -ne $argumentListProperty) { $argumentList = $argumentListProperty.GetValue($ProcessStartInfo, $null) } if ($null -ne $argumentList) { foreach ($arg in $Arguments) { [void]$argumentList.Add($arg) } return } $ProcessStartInfo.Arguments = (($Arguments | ForEach-Object { ConvertTo-WindowsArgument $_ }) -join " ") } function Start-ProcessWithInputFile { param( [string]$FilePath, [string[]]$Arguments, [string]$InputFile ) $commandParts = @((ConvertTo-WindowsArgument $FilePath)) $commandParts += @($Arguments | ForEach-Object { ConvertTo-WindowsArgument $_ }) $commandLine = (($commandParts -join " ") + " < " + (ConvertTo-WindowsArgument $InputFile)) & cmd.exe /d /s /c $commandLine if ($LASTEXITCODE -ne 0) { Fail "command failed ($LASTEXITCODE): $FilePath $($Arguments -join ' ')" } } function Start-ProcessWithInputText { param( [string]$FilePath, [string[]]$Arguments, [string]$InputText ) $inputFile = [System.IO.Path]::GetTempFileName() try { [System.IO.File]::WriteAllText($inputFile, $InputText, $Utf8NoBom) Start-ProcessWithInputFile -FilePath $FilePath -Arguments $Arguments -InputFile $inputFile } finally { Remove-Item -LiteralPath $inputFile -Force -ErrorAction SilentlyContinue } } function Remove-IfExists { param([string]$Path) if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } } function Copy-FullPayload { Write-DeployLog "building full payload from current working tree: $SourceDir" Write-DeployLog "full include paths: $($FullIncludePaths -join ' ')" $payloadTar = Join-Path $WorkDir "payload-copy.tar" $tarArgs = @( "-cf", $payloadTar, "-C", $SourceAbs, "--exclude=.git", "--exclude=.idea", "--exclude=.vscode", "--exclude=.tmp", "--exclude=.gitignore", "--exclude=*.log", "--exclude=release-*.zip", "--exclude=*.zip", "--exclude=__pycache__", "--exclude=*.pyc" ) if (-not $IncludeEnv) { $tarArgs += "--exclude=.env" } if (-not $IncludeVendor) { $tarArgs += "--exclude=vendor" } if (-not $IncludeRuntime) { $tarArgs += "--exclude=runtime" } if (-not $IncludeUploads) { $tarArgs += "--exclude=public/uploads" } if (-not $IncludePublicBuilds) { $tarArgs += @("--exclude=public/admin", "--exclude=public/mobile") } if (-not $IncludeCrawlerData) { $tarArgs += @( "--exclude=public/dongqiudi-crawler/data", "--exclude=public/dongqiudi-crawler/logs", "--exclude=public/dongqiudi-crawler/venv", "--exclude=public/dongqiudi-crawler/__pycache__" ) } $tarArgs += $FullIncludePaths Invoke-Native "tar" $tarArgs Invoke-Native "tar" @("-xf", $payloadTar, "-C", $PayloadDir) } function Strip-SourcePrefix { param([string]$Path) if ([string]::IsNullOrEmpty($GitPathPrefix)) { return $Path } $prefix = "$GitPathPrefix/" if ($Path.StartsWith($prefix)) { return $Path.Substring($prefix.Length) } return $Path } function Test-DeployIncludedPath { param([string]$RelativePath) foreach ($include in $FullIncludePaths) { if ($RelativePath -eq $include -or $RelativePath.StartsWith("$include/")) { return $true } } return $false } function Test-UnderSourceDir { param([string]$GitPath) if ([string]::IsNullOrEmpty($GitPathPrefix)) { return (Test-DeployIncludedPath $GitPath) } if (-not $GitPath.StartsWith("$GitPathPrefix/")) { return $false } return (Test-DeployIncludedPath (Strip-SourcePrefix $GitPath)) } function Get-ShortCommit { param([string]$Commit) try { return (Invoke-NativeOutput "git" @("-C", $GitCwd, "rev-parse", "--short", $Commit) | Select-Object -First 1) } catch { return $Commit } } function Write-FileListLog { param( [string]$Title, [string]$Prefix, [string[]]$Items ) Write-DeployLog $Title $list = @($Items | Where-Object { $_ } | Sort-Object -Unique) if ($list.Count -eq 0) { Write-Host " (none)" return } foreach ($item in $list) { Write-Host " $Prefix $item" } } function Write-UpdateLog { Write-DeployLog "update log:" if ($Mode -eq "incremental") { $fromShort = Get-ShortCommit $FromCommit $toShort = Get-ShortCommit $ToCommit Write-DeployLog "commit range: $fromShort -> $toShort" $logArgs = @("-C", $GitCwd, "-c", "i18n.logOutputEncoding=utf-8", "log", "--encoding=UTF-8", "--no-merges", "--date=format:%Y-%m-%d %H:%M:%S", "--pretty=format: * %h %ad %an %s", "$FromCommit..$ToCommit") if ($SourceIsGitLink) { Write-DeployLog "$SourceDir is a root gitlink, showing root repository commits for the selected range" } elseif (-not [string]::IsNullOrEmpty($GitPathPrefix)) { $logArgs += @("--", $GitPathPrefix) } $commitLines = @(& git @logArgs) Write-DeployLog "commits:" if ($commitLines.Count -gt 0) { $commitLines | ForEach-Object { Write-Host $_ } } else { Write-Host " (none)" } Write-FileListLog "changed files:" "+" $ChangedDisplayPaths Write-FileListLog "deleted files:" "-" $DeletedDisplayPaths } else { Write-DeployLog "mode: full deploy from current working tree" Write-DeployLog "full include paths: $($FullIncludePaths -join ' ')" $commitLines = @(& git -C $GitCwd -c i18n.logOutputEncoding=utf-8 log -1 --encoding=UTF-8 "--date=format:%Y-%m-%d %H:%M:%S" "--pretty=format: * %h %ad %an %s") Write-DeployLog "latest commit:" if ($commitLines.Count -gt 0) { $commitLines | ForEach-Object { Write-Host $_ } } else { Write-Host " (none)" } } } function Build-IncrementalPayload { if ($SourceIsGitLink) { Write-DeployLog "$SourceDir is tracked as a root gitlink; exact file-level diff is unavailable without $SourceDir/.git" Write-DeployLog "building fallback payload from current working tree include paths: $($FullIncludePaths -join ' ')" $script:ChangedDisplayPaths = @($FullIncludePaths) Copy-FullPayload return } $diffArgs = @("-C", $GitCwd, "diff", "--name-status", "--find-renames", $FromCommit, $ToCommit) if (-not [string]::IsNullOrEmpty($GitPathPrefix)) { $diffArgs += @("--", $GitPathPrefix) } $rawDiff = @(& git @diffArgs) if ($LASTEXITCODE -ne 0) { Fail "git diff failed" } $changedGitPaths = New-Object System.Collections.Generic.List[string] $script:ChangedDisplayPaths = @() $script:DeletedDisplayPaths = @() foreach ($line in $rawDiff) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $parts = $line -split "`t" $status = $parts[0] if ($status -eq "D") { $path1 = $parts[1] if (Test-UnderSourceDir $path1) { $script:DeletedDisplayPaths += (Strip-SourcePrefix $path1) } } elseif ($status -like "R*" -or $status -like "C*") { if ($parts.Count -lt 3) { continue } $path1 = $parts[1] $path2 = $parts[2] if (Test-UnderSourceDir $path2) { [void]$changedGitPaths.Add($path2) $script:ChangedDisplayPaths += (Strip-SourcePrefix $path2) if ($status -like "R*" -and (Test-UnderSourceDir $path1)) { $script:DeletedDisplayPaths += (Strip-SourcePrefix $path1) } } } else { $path1 = $parts[1] if (Test-UnderSourceDir $path1) { [void]$changedGitPaths.Add($path1) $script:ChangedDisplayPaths += (Strip-SourcePrefix $path1) } } } $script:ChangedDisplayPaths = @($script:ChangedDisplayPaths | Sort-Object -Unique) $script:DeletedDisplayPaths = @($script:DeletedDisplayPaths | Sort-Object -Unique) $changedGitPaths = @($changedGitPaths | Sort-Object -Unique) if ($changedGitPaths.Count -eq 0 -and $script:DeletedDisplayPaths.Count -eq 0) { Write-DeployLog "no changed files detected between $FromCommit and $ToCommit under $SourceDir" $script:NoChanges = $true return } if ($script:DeletedDisplayPaths.Count -gt 0) { Set-Content -LiteralPath $DeletedPathsFile -Value $script:DeletedDisplayPaths -Encoding UTF8 } if ($changedGitPaths.Count -gt 0) { $incrementalTar = Join-Path $WorkDir "incremental.tar" Write-DeployLog "building incremental payload from commit $ToCommit" Invoke-Native "git" (@("-C", $GitCwd, "archive", "--format=tar", "--output=$incrementalTar", $ToCommit, "--") + $changedGitPaths) Invoke-Native "tar" @("-xf", $incrementalTar, "-C", $ExtractDir) if ($SourceIsGitRepo) { $extractSource = $ExtractDir } else { $extractSource = Join-Path $ExtractDir $SourceDir } if (Test-Path -LiteralPath $extractSource) { Get-ChildItem -LiteralPath $extractSource -Force | Copy-Item -Destination $PayloadDir -Recurse -Force } } } $SourceDir = Normalize-DeployDir $SourceDir $RemoteDir = Normalize-DeployDir $RemoteDir $RemoteTmp = Normalize-DeployDir $RemoteTmp if ($AutoIncremental -and ($From -or $To)) { Fail "-AutoIncremental cannot be used with -From/-To" } if ($AutoIncremental) { $Mode = "incremental" $FromCommit = "HEAD~1" $ToCommit = "HEAD" } elseif ($From -or $To) { if (-not $From -or -not $To) { Fail "incremental deploy requires both -From and -To" } $Mode = "incremental" $FromCommit = $From $ToCommit = $To } else { $Mode = "full" $FromCommit = "" $ToCommit = "" } Require-Command "git" Require-Command "tar" Require-Command $SshBin $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path $SourceAbs = Join-Path $RepoRoot $SourceDir if (-not (Test-Path -LiteralPath $SourceAbs -PathType Container)) { Fail "source directory not found: $SourceDir" } $FullIncludePaths = @("app", "config", "extend", "public", "route") $GitCwd = $RepoRoot $GitPathPrefix = $SourceDir $SourceIsGitRepo = $false $SourceIsGitLink = $false $sourceGitTop = (& git -C $SourceAbs rev-parse --show-toplevel 2>$null | Select-Object -First 1) if ($LASTEXITCODE -eq 0 -and $sourceGitTop -and (Normalize-ComparePath $sourceGitTop) -eq (Normalize-ComparePath $SourceAbs)) { $GitCwd = $SourceAbs $GitPathPrefix = "" $SourceIsGitRepo = $true } else { & git -C $RepoRoot rev-parse --show-toplevel *> $null if ($LASTEXITCODE -ne 0 -and $Mode -eq "incremental") { Fail "incremental deploy requires git metadata in $SourceDir or repo root" } } $lsFiles = @(& git -C $RepoRoot ls-files -s -- $SourceDir) if (-not $SourceIsGitRepo -and ($lsFiles | Where-Object { $_ -match "^160000\s" })) { $SourceIsGitLink = $true } if ($Mode -eq "incremental") { & git -C $GitCwd rev-parse --verify "$FromCommit^{commit}" *> $null if ($LASTEXITCODE -ne 0) { Fail "invalid -From commit: $FromCommit" } & git -C $GitCwd rev-parse --verify "$ToCommit^{commit}" *> $null if ($LASTEXITCODE -ne 0) { Fail "invalid -To commit: $ToCommit" } $FromCommit = (Invoke-NativeOutput "git" @("-C", $GitCwd, "rev-parse", $FromCommit) | Select-Object -First 1) $ToCommit = (Invoke-NativeOutput "git" @("-C", $GitCwd, "rev-parse", $ToCommit) | Select-Object -First 1) } $TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "sport-era-deploy" New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null $WorkDir = Join-Path $TempRoot ("deploy-server-" + [System.Guid]::NewGuid().ToString("N")) $PackageRoot = Join-Path $WorkDir "package" $PayloadDir = Join-Path $PackageRoot "payload" $ExtractDir = Join-Path $WorkDir "extract" $ArchivePath = Join-Path $WorkDir ("{0}-{1}.tar.gz" -f ($SourceDir -replace "[\\/]", "-"), $Mode) $DeletedPathsFile = Join-Path $PackageRoot ".deleted-paths.txt" $DeployInfoFile = Join-Path $PackageRoot ".deploy-info.txt" $script:ChangedDisplayPaths = @() $script:DeletedDisplayPaths = @() $script:NoChanges = $false try { New-Item -ItemType Directory -Force -Path $PayloadDir, $ExtractDir | Out-Null New-Item -ItemType File -Force -Path $DeletedPathsFile | Out-Null if ($Mode -eq "full") { Copy-FullPayload } else { Build-IncrementalPayload if ($script:NoChanges) { return } } Write-UpdateLog $deployInfo = @( "mode=$Mode", "source_dir=$SourceDir", "host=$HostName", "remote_dir=$RemoteDir", "built_at=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" ) if ($Mode -eq "incremental") { $deployInfo += @( "auto_incremental=$([int]$AutoIncremental.IsPresent)", "source_is_gitlink=$([int]$SourceIsGitLink)", "from_commit=$FromCommit", "to_commit=$ToCommit", "changed_count=$($script:ChangedDisplayPaths.Count)", "deleted_count=$($script:DeletedDisplayPaths.Count)" ) } Set-Content -LiteralPath $DeployInfoFile -Value $deployInfo -Encoding UTF8 Write-DeployLog "packing archive: $ArchivePath" Invoke-Native "tar" @("-czf", $ArchivePath, "-C", $PackageRoot, ".") $packageSizeKb = [Math]::Ceiling((Get-Item -LiteralPath $ArchivePath).Length / 1KB) Write-DeployLog "package size: ${packageSizeKb}K" if ($DryRun) { Write-DeployLog "dry-run enabled, skip upload/install" return } $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $pidValue = $PID $remoteWorkDir = "$RemoteTmp/$($SourceDir -replace '[\\/]', '-')-deploy-$timestamp-$pidValue" $remoteArchive = "$RemoteTmp/$($SourceDir -replace '[\\/]', '-')-$Mode-$timestamp.tar.gz" $remoteTmpQ = ConvertTo-RemoteSingleQuoted $RemoteTmp $remoteArchiveQ = ConvertTo-RemoteSingleQuoted $remoteArchive Write-DeployLog "uploading archive to ${HostName}:$remoteArchive" $uploadCommand = "mkdir -p $remoteTmpQ && cat > $remoteArchiveQ" Start-ProcessWithInputFile -FilePath $SshBin -Arguments @($HostName, $uploadCommand) -InputFile $ArchivePath $postInstallB64 = "" if ($PostInstall) { $postInstallB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($PostInstall)) } $useSudoValue = if ($Sudo) { "1" } else { "0" } $keepRemoteTempValue = if ($KeepRemoteTemp) { "1" } else { "0" } $remoteCommand = @( "REMOTE_DIR=$(ConvertTo-RemoteSingleQuoted $RemoteDir)", "REMOTE_ARCHIVE=$(ConvertTo-RemoteSingleQuoted $remoteArchive)", "REMOTE_WORK_DIR=$(ConvertTo-RemoteSingleQuoted $remoteWorkDir)", "KEEP_REMOTE_TEMP='$keepRemoteTempValue'", "POST_INSTALL_B64='$postInstallB64'", "USE_SUDO='$useSudoValue'", "bash -s" ) -join " " $remoteScript = @' set -euo pipefail remote_now_ms() { date +%s%3N } remote_format_duration() { local ms="$1" local seconds=$((ms / 1000)) local millis=$((ms % 1000)) local minutes=$((seconds / 60)) seconds=$((seconds % 60)) if [[ "$minutes" -gt 0 ]]; then printf '%dm%02d.%03ds' "$minutes" "$seconds" "$millis" else printf '%d.%03ds' "$seconds" "$millis" fi } REMOTE_START_MS="$(remote_now_ms)" REMOTE_LAST_LOG_MS="$REMOTE_START_MS" remote_log() { local current_ms local total_ms local step_ms current_ms="$(remote_now_ms)" total_ms=$((current_ms - REMOTE_START_MS)) step_ms=$((current_ms - REMOTE_LAST_LOG_MS)) REMOTE_LAST_LOG_MS="$current_ms" printf '[deploy][remote][+%s][total %s] %s\n' "$(remote_format_duration "$step_ms")" "$(remote_format_duration "$total_ms")" "$*" } run_priv() { if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo "$@" else "$@" fi } copy_payload_without_metadata() { local payload_dir="$1" local target_dir="$2" while IFS= read -r -d '' dir; do local rel="${dir#./}" [[ "$rel" != "." ]] || continue run_priv mkdir -p "$target_dir/$rel" done < <(cd "$payload_dir" && find . -type d -print0) while IFS= read -r -d '' item; do local rel="${item#./}" local source_path="$payload_dir/$rel" local target_path="$target_dir/$rel" local target_parent target_parent="$(dirname "$target_path")" run_priv mkdir -p "$target_parent" if [[ -L "$source_path" ]]; then local link_target link_target="$(readlink "$source_path")" run_priv rm -f "$target_path" run_priv ln -s "$link_target" "$target_path" elif [[ -f "$source_path" ]]; then run_priv cp -f "$source_path" "$target_path" else echo "[deploy][remote][warn] skip unsupported payload entry: $rel" >&2 fi done < <(cd "$payload_dir" && find . ! -type d -print0) } install_payload() { local payload_dir="$1" local target_dir="$2" if command -v rsync >/dev/null 2>&1; then remote_log "install payload into remote dir with rsync" run_priv rsync -r --links --no-perms --no-owner --no-group "$payload_dir"/ "$target_dir"/ else remote_log "install payload into remote dir with file copy" copy_payload_without_metadata "$payload_dir" "$target_dir" fi } remote_log "prepare remote work dir: $REMOTE_WORK_DIR" mkdir -p "$REMOTE_WORK_DIR" remote_log "extract uploaded archive" tar -xzf "$REMOTE_ARCHIVE" -C "$REMOTE_WORK_DIR" remote_log "ensure remote dir: $REMOTE_DIR" run_priv mkdir -p "$REMOTE_DIR" if [[ -d "$REMOTE_WORK_DIR/payload" ]]; then install_payload "$REMOTE_WORK_DIR/payload" "$REMOTE_DIR" fi if [[ -f "$REMOTE_WORK_DIR/.deleted-paths.txt" ]]; then remote_log "apply deleted paths" while IFS= read -r rel; do [[ -n "$rel" ]] || continue case "$rel" in *".."*|/*) echo "[deploy][remote][warn] skip unsafe delete path: $rel" >&2 continue ;; esac run_priv rm -rf "$REMOTE_DIR/$rel" done < "$REMOTE_WORK_DIR/.deleted-paths.txt" fi if [[ -n "${POST_INSTALL_B64:-}" ]]; then remote_log "run post-install command" POST_INSTALL_CMD="$(printf '%s' "$POST_INSTALL_B64" | base64 -d)" cd "$REMOTE_DIR" if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo bash -lc "$POST_INSTALL_CMD" else bash -lc "$POST_INSTALL_CMD" fi fi if [[ "${KEEP_REMOTE_TEMP:-0}" != "1" ]]; then remote_log "clean remote temp files" rm -rf "$REMOTE_WORK_DIR" "$REMOTE_ARCHIVE" fi remote_log "remote install finished" '@ Write-DeployLog "installing on server: $RemoteDir" Start-ProcessWithInputText -FilePath $SshBin -Arguments @($HostName, $remoteCommand) -InputText $remoteScript Write-DeployLog "deploy finished" Write-DeployLog "mode: $Mode" Write-DeployLog "source: $SourceDir" Write-DeployLog "remote: ${HostName}:$RemoteDir" } finally { if ($WorkDir -and (Test-Path -LiteralPath $WorkDir)) { Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue } }