952 lines
30 KiB
PowerShell
952 lines
30 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$SourceDir = ".",
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RemoteDir,
|
|
[string]$UniappReleasePath = "",
|
|
[string]$HostName = "sbnews",
|
|
[string]$SshBin = "ssh.exe",
|
|
[switch]$AutoIncremental,
|
|
[ValidateRange(1, 1000)]
|
|
[int]$AutoIncrementalCommits = 1,
|
|
[string]$AutoCommitMessage = "",
|
|
[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 {
|
|
# Remote shells like `bash -s` choke on Windows CRLF in stdin scripts.
|
|
$normalizedInputText = $InputText.Replace("`r`n", "`n").Replace("`r", "`n")
|
|
[System.IO.File]::WriteAllText($inputFile, $normalizedInputText, $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 Test-IsRepoRootDeploy {
|
|
return ($SourceDir -eq "." -or [string]::IsNullOrWhiteSpace($SourceDir))
|
|
}
|
|
|
|
function Get-SourceLabel {
|
|
if (Test-IsRepoRootDeploy) {
|
|
return "repo-root"
|
|
}
|
|
return $SourceDir
|
|
}
|
|
|
|
function Get-DeployPathspec {
|
|
if (Test-IsRepoRootDeploy) {
|
|
return @($FullIncludePaths)
|
|
}
|
|
if ([string]::IsNullOrEmpty($GitPathPrefix)) {
|
|
return @(".")
|
|
}
|
|
return @($GitPathPrefix)
|
|
}
|
|
|
|
function Get-FullIncludePaths {
|
|
switch ($SourceDir.ToLowerInvariant()) {
|
|
"." {
|
|
return @("server", "uniapp", "admin", "docker")
|
|
}
|
|
"server" {
|
|
return @("app", "config", "extend", "public", "route")
|
|
}
|
|
"uniapp" {
|
|
return @(".")
|
|
}
|
|
default {
|
|
return @(".")
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-ArchiveExcludePatterns {
|
|
$patterns = @(
|
|
".git",
|
|
".idea",
|
|
".vscode",
|
|
".tmp",
|
|
".hbuilderx",
|
|
".gitignore",
|
|
"*.log",
|
|
"release-*.zip",
|
|
"*.zip",
|
|
"__pycache__",
|
|
"*.pyc",
|
|
"node_modules",
|
|
"dist"
|
|
)
|
|
|
|
switch ($SourceDir.ToLowerInvariant()) {
|
|
"." {
|
|
$patterns += @("uniapp/node_modules", "uniapp/dist", "admin/node_modules", "admin/dist")
|
|
if (-not $IncludeEnv) {
|
|
$patterns += "server/.env"
|
|
}
|
|
if (-not $IncludeVendor) {
|
|
$patterns += "server/vendor"
|
|
}
|
|
if (-not $IncludeRuntime) {
|
|
$patterns += "server/runtime"
|
|
}
|
|
if (-not $IncludeUploads) {
|
|
$patterns += "server/public/uploads"
|
|
}
|
|
if (-not $IncludePublicBuilds) {
|
|
$patterns += @("server/public/admin", "server/public/mobile")
|
|
}
|
|
if (-not $IncludeCrawlerData) {
|
|
$patterns += @(
|
|
"server/public/dongqiudi-crawler/data",
|
|
"server/public/dongqiudi-crawler/logs",
|
|
"server/public/dongqiudi-crawler/venv",
|
|
"server/public/dongqiudi-crawler/__pycache__"
|
|
)
|
|
}
|
|
}
|
|
"server" {
|
|
if (-not $IncludeEnv) {
|
|
$patterns += ".env"
|
|
}
|
|
if (-not $IncludeVendor) {
|
|
$patterns += "vendor"
|
|
}
|
|
if (-not $IncludeRuntime) {
|
|
$patterns += "runtime"
|
|
}
|
|
if (-not $IncludeUploads) {
|
|
$patterns += "public/uploads"
|
|
}
|
|
if (-not $IncludePublicBuilds) {
|
|
$patterns += @("public/admin", "public/mobile")
|
|
}
|
|
if (-not $IncludeCrawlerData) {
|
|
$patterns += @(
|
|
"public/dongqiudi-crawler/data",
|
|
"public/dongqiudi-crawler/logs",
|
|
"public/dongqiudi-crawler/venv",
|
|
"public/dongqiudi-crawler/__pycache__"
|
|
)
|
|
}
|
|
}
|
|
"uniapp" {
|
|
if (-not $IncludeEnv) {
|
|
$patterns += ".env"
|
|
}
|
|
}
|
|
}
|
|
|
|
return @($patterns)
|
|
}
|
|
|
|
function Commit-SourceChangesIfNeeded {
|
|
param(
|
|
[string]$Repository,
|
|
[string[]]$Pathspec
|
|
)
|
|
|
|
$statusArgs = @("-C", $Repository, "status", "--porcelain=v1", "--untracked-files=all", "--") + $Pathspec
|
|
$statusLines = @(& git @statusArgs)
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Fail "git status failed"
|
|
}
|
|
|
|
if ($statusLines.Count -eq 0) {
|
|
return
|
|
}
|
|
|
|
$unmerged = @($statusLines | Where-Object { $_ -match "^(DD|AU|UD|UA|DU|AA|UU)" })
|
|
if ($unmerged.Count -gt 0) {
|
|
Write-DeployLog "unmerged changes detected under $(Get-SourceLabel):"
|
|
$unmerged | Select-Object -First 20 | ForEach-Object { Write-Host " $_" }
|
|
Fail "resolve merge conflicts under $(Get-SourceLabel) before deploy"
|
|
}
|
|
|
|
Write-DeployLog "uncommitted changes detected under $(Get-SourceLabel); auto committing before deploy:"
|
|
$maxDisplay = 80
|
|
$statusLines | Select-Object -First $maxDisplay | ForEach-Object { Write-Host " $_" }
|
|
if ($statusLines.Count -gt $maxDisplay) {
|
|
Write-Host " ... and $($statusLines.Count - $maxDisplay) more"
|
|
}
|
|
|
|
Invoke-Native "git" (@("-C", $Repository, "add", "-A", "--") + $Pathspec)
|
|
|
|
& git -C $Repository diff --cached --quiet -- $Pathspec
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-DeployLog "no committable root changes detected after git add; continue with current working tree payload"
|
|
return
|
|
}
|
|
if ($LASTEXITCODE -ne 1) {
|
|
Fail "git diff --cached failed"
|
|
}
|
|
|
|
$commitMessage = $AutoCommitMessage
|
|
if ([string]::IsNullOrWhiteSpace($commitMessage)) {
|
|
$commitMessage = "deploy: auto commit $(Get-SourceLabel) changes $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
|
}
|
|
|
|
Invoke-Native "git" (@("-C", $Repository, "commit", "-m", $commitMessage, "--") + $Pathspec)
|
|
$newCommit = (Invoke-NativeOutput "git" @("-C", $Repository, "rev-parse", "--short", "HEAD") | Select-Object -First 1)
|
|
Write-DeployLog "auto commit created: $newCommit"
|
|
}
|
|
|
|
function Copy-WorkingTreePayloadPaths {
|
|
param(
|
|
[string[]]$IncludePaths,
|
|
[string]$Reason
|
|
)
|
|
|
|
if ($IncludePaths.Count -eq 0) {
|
|
return
|
|
}
|
|
|
|
Write-DeployLog $Reason
|
|
Write-DeployLog "working tree include paths: $($IncludePaths -join ' ')"
|
|
|
|
$payloadTar = Join-Path $WorkDir "payload-copy.tar"
|
|
$tarArgs = @(
|
|
"-cf", $payloadTar,
|
|
"-C", $SourceAbs
|
|
)
|
|
|
|
foreach ($pattern in (Get-ArchiveExcludePatterns)) {
|
|
if (-not [string]::IsNullOrWhiteSpace($pattern)) {
|
|
$tarArgs += "--exclude=$pattern"
|
|
}
|
|
}
|
|
|
|
$tarArgs += $IncludePaths
|
|
Invoke-Native "tar" $tarArgs
|
|
Invoke-Native "tar" @("-xf", $payloadTar, "-C", $PayloadDir)
|
|
}
|
|
|
|
function Copy-FullPayload {
|
|
Copy-WorkingTreePayloadPaths `
|
|
-IncludePaths $FullIncludePaths `
|
|
-Reason "building full payload from current working tree: $(Get-SourceLabel)"
|
|
}
|
|
|
|
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 ($include -eq ".") {
|
|
return $true
|
|
}
|
|
if ($RelativePath -eq $include -or $RelativePath.StartsWith("$include/")) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-RootGitLinkIncludePaths {
|
|
if (-not (Test-IsRepoRootDeploy)) {
|
|
return @()
|
|
}
|
|
|
|
$lsFiles = @(& git -C $RepoRoot ls-files -s -- $FullIncludePaths)
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Fail "git ls-files failed"
|
|
}
|
|
|
|
return @(
|
|
$lsFiles |
|
|
Where-Object { $_ -match "^160000\s+\S+\s+\d+\t(.+)$" } |
|
|
ForEach-Object { $Matches[1] } |
|
|
Where-Object { Test-DeployIncludedPath $_ } |
|
|
Sort-Object -Unique
|
|
)
|
|
}
|
|
|
|
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"
|
|
if ($AutoIncremental) {
|
|
Write-DeployLog "auto incremental commits: $AutoIncrementalCommits"
|
|
}
|
|
|
|
$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
|
|
}
|
|
|
|
$rootGitLinkPayloadPaths = @()
|
|
if (Test-IsRepoRootDeploy) {
|
|
$rootGitLinkPayloadPaths = @($RootGitLinkIncludePaths)
|
|
}
|
|
|
|
$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:DeletedDisplayPaths = @($script:DeletedDisplayPaths | Sort-Object -Unique)
|
|
$changedGitPaths = @($changedGitPaths | Sort-Object -Unique)
|
|
|
|
if ($rootGitLinkPayloadPaths.Count -gt 0) {
|
|
$script:ChangedDisplayPaths += @($rootGitLinkPayloadPaths | ForEach-Object { "$_/" })
|
|
}
|
|
$script:ChangedDisplayPaths = @($script:ChangedDisplayPaths | Sort-Object -Unique)
|
|
|
|
if ($changedGitPaths.Count -eq 0 -and $script:DeletedDisplayPaths.Count -eq 0 -and $rootGitLinkPayloadPaths.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
|
|
}
|
|
}
|
|
|
|
if ($rootGitLinkPayloadPaths.Count -gt 0) {
|
|
Copy-WorkingTreePayloadPaths `
|
|
-IncludePaths $rootGitLinkPayloadPaths `
|
|
-Reason "building working tree payload for root gitlink include paths"
|
|
}
|
|
}
|
|
|
|
$SourceDir = Normalize-DeployDir $SourceDir
|
|
$RemoteDir = Normalize-DeployDir $RemoteDir
|
|
$RemoteTmp = Normalize-DeployDir $RemoteTmp
|
|
$SourceLabel = Get-SourceLabel
|
|
|
|
if ($AutoIncremental -and ($From -or $To)) {
|
|
Fail "-AutoIncremental cannot be used with -From/-To"
|
|
}
|
|
|
|
if (-not $AutoIncremental -and $PSBoundParameters.ContainsKey("AutoIncrementalCommits")) {
|
|
Fail "-AutoIncrementalCommits can only be used with -AutoIncremental"
|
|
}
|
|
|
|
if ($AutoIncremental) {
|
|
$Mode = "incremental"
|
|
$FromCommit = "HEAD~$AutoIncrementalCommits"
|
|
$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 = @(Get-FullIncludePaths)
|
|
$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
|
|
}
|
|
|
|
$DeployPathspec = @(Get-DeployPathspec)
|
|
$RootGitLinkIncludePaths = @(Get-RootGitLinkIncludePaths)
|
|
if ($RootGitLinkIncludePaths.Count -gt 0) {
|
|
Write-DeployLog "root gitlink include paths will be copied from working tree: $($RootGitLinkIncludePaths -join ' ')"
|
|
}
|
|
Commit-SourceChangesIfNeeded -Repository $GitCwd -Pathspec $DeployPathspec
|
|
|
|
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 $SourceLabel, $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=$SourceLabel",
|
|
"host=$HostName",
|
|
"remote_dir=$RemoteDir",
|
|
"uniapp_release_path=$UniappReleasePath",
|
|
"built_at=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
|
)
|
|
if ($Mode -eq "incremental") {
|
|
$autoIncrementalCommitCount = if ($AutoIncremental) { $AutoIncrementalCommits } else { 0 }
|
|
$deployInfo += @(
|
|
"auto_incremental=$([int]$AutoIncremental.IsPresent)",
|
|
"auto_incremental_commits=$autoIncrementalCommitCount",
|
|
"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/$SourceLabel-deploy-$timestamp-$pidValue"
|
|
$remoteArchive = "$RemoteTmp/$SourceLabel-$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)",
|
|
"UNIAPP_RELEASE_PATH=$(ConvertTo-RemoteSingleQuoted $UniappReleasePath)",
|
|
"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 [[ -n "${UNIAPP_RELEASE_PATH:-}" ]]; then
|
|
export UNIAPP_RELEASE_PATH
|
|
fi
|
|
if [[ "${USE_SUDO:-0}" == "1" ]]; then
|
|
if [[ -n "${UNIAPP_RELEASE_PATH:-}" ]]; then
|
|
sudo env UNIAPP_RELEASE_PATH="$UNIAPP_RELEASE_PATH" bash -lc "$POST_INSTALL_CMD"
|
|
else
|
|
sudo bash -lc "$POST_INSTALL_CMD"
|
|
fi
|
|
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: $SourceLabel"
|
|
Write-DeployLog "remote: ${HostName}:$RemoteDir"
|
|
} finally {
|
|
if ($WorkDir -and (Test-Path -LiteralPath $WorkDir)) {
|
|
Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|