GuDingZiChan/scripts/publish-to-server.ps1
2026-07-10 17:07:59 +08:00

380 lines
12 KiB
PowerShell

param(
[string]$HostName = "",
[string]$User = "root",
[int]$Port = 22,
[string]$RemoteDir = "/opt/equipment-management",
[string]$RemoteArchive = "/tmp/equipment-management-release.tar.gz",
[switch]$SkipDeploy,
[switch]$SkipBackup,
[switch]$AllowDirty,
[switch]$IncludeDownloadArtifacts,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8NoBom
$OutputEncoding = $utf8NoBom
$projectRoot = Split-Path -Parent $PSScriptRoot
$password = if ($env:DEPLOY_PASSWORD) { $env:DEPLOY_PASSWORD } else { "" }
$hostKey = if ($env:DEPLOY_HOST_KEY) { $env:DEPLOY_HOST_KEY } else { "" }
$archiveName = "equipment-management-release.tar.gz"
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
$fileListPath = Join-Path ([System.IO.Path]::GetTempPath()) "equipment-management-files.txt"
$expectedRemoteDir = "/opt/equipment-management"
$expectedRemoteArchive = "/tmp/equipment-management-release.tar.gz"
$releasePaths = @(
"backend",
"frontend",
"scripts/backup-equipment.sh",
".env.example",
"AGENTS.md",
"BACKUP.md",
"Dockerfile",
"README.md",
"deploy.sh",
"docker-compose.yml"
)
if ($IncludeDownloadArtifacts) {
$releasePaths += "downloads"
}
function Get-CommandPath([string]$name) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if (-not $cmd) {
throw "Command not found: $name"
}
return $cmd.Source
}
function Get-TarCommandPath {
$gitCommand = Get-Command "git" -ErrorAction SilentlyContinue
if ($gitCommand) {
$gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent
$gitTar = Join-Path $gitRoot "usr\bin\tar.exe"
if (Test-Path -LiteralPath $gitTar -PathType Leaf) {
return $gitTar
}
}
return Get-CommandPath "tar"
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[string]$Description = "External command"
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Description failed with exit code $LASTEXITCODE"
}
}
function ConvertTo-ShellLiteral([string]$value) {
if ($value.Contains("'") -or $value.Contains([char]13) -or $value.Contains([char]10)) {
throw "Remote paths cannot contain single quotes or line breaks."
}
return "'$value'"
}
function Get-DownloadArtifactFiles([string]$rootPath) {
$downloadsRoot = [System.IO.Path]::GetFullPath((Join-Path $rootPath "downloads"))
$downloadsPrefix = $downloadsRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
$manifestFiles = @(Get-ChildItem -LiteralPath $downloadsRoot -Recurse -Filter "manifest.json" -File)
$artifactPaths = @()
foreach ($manifestFile in $manifestFiles) {
try {
$manifest = [System.IO.File]::ReadAllText(
$manifestFile.FullName,
[System.Text.Encoding]::UTF8
) | ConvertFrom-Json
} catch {
throw "Invalid download manifest JSON: $($manifestFile.FullName)"
}
$sourceType = [string]$manifest.source_type
if ($sourceType -notin @("server_release", "company_archive")) {
continue
}
$relativePath = [string]$manifest.relative_path
if (-not $relativePath) {
$relativePath = [string]$manifest.relativePath
}
$relativePath = $relativePath.Replace("\", "/")
if (-not $relativePath -or -not $relativePath.StartsWith("downloads/", [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Download manifest must reference a path under downloads/: $($manifestFile.FullName)"
}
$segments = @($relativePath -split "/")
if ($segments -contains ".." -or $segments -contains ".") {
throw "Download manifest contains an unsafe relative path: $relativePath"
}
$artifactFullPath = [System.IO.Path]::GetFullPath(
(Join-Path $rootPath ($relativePath.Replace("/", [System.IO.Path]::DirectorySeparatorChar)))
)
if (-not $artifactFullPath.StartsWith($downloadsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Download artifact escapes the downloads directory: $relativePath"
}
if (-not (Test-Path -LiteralPath $artifactFullPath -PathType Leaf)) {
throw "Download artifact referenced by manifest is missing: $relativePath"
}
$artifactPaths += $relativePath
}
return @($artifactPaths | Sort-Object -Unique)
}
if (-not $HostName -and $env:DEPLOY_HOST) {
$HostName = $env:DEPLOY_HOST
}
if ($User -notmatch '^[A-Za-z0-9._-]+$') {
throw "Remote user contains unsupported characters."
}
if ($Port -lt 1 -or $Port -gt 65535) {
throw "Remote port must be between 1 and 65535."
}
if ($RemoteDir -cne $expectedRemoteDir) {
throw "RemoteDir is locked to $expectedRemoteDir for fixed-asset deployments."
}
if ($RemoteArchive -cne $expectedRemoteArchive) {
throw "RemoteArchive is locked to $expectedRemoteArchive."
}
Set-Location $projectRoot
$worktreeStatus = @(& git status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0) {
throw "Failed to read Git worktree status."
}
if ($worktreeStatus.Count -gt 0 -and -not $AllowDirty) {
throw "The worktree is dirty. Commit or clean it before publishing, or use -AllowDirty only for an explicit emergency release."
}
if ($worktreeStatus.Count -gt 0) {
Write-Warning "Dirty worktree explicitly allowed. Only allowlisted release files will be packaged."
}
$gitFileArgs = @("-c", "core.quotepath=false", "ls-files", "--cached", "--others", "--exclude-standard", "--") + $releasePaths
$files = @(& git @gitFileArgs | Sort-Object -Unique)
if ($LASTEXITCODE -ne 0) {
throw "Failed to build the release allowlist."
}
if ($files.Count -eq 0) {
throw "No files matched the release allowlist."
}
$artifactFiles = if ($IncludeDownloadArtifacts) {
@(Get-DownloadArtifactFiles $projectRoot)
} else {
@()
}
$files = @(($files + $artifactFiles) | Sort-Object -Unique)
$missingFiles = @($files | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) })
if ($missingFiles.Count -gt 0) {
throw "Release list contains missing or non-file paths: $($missingFiles -join ', ')"
}
$totalBytes = ($files | ForEach-Object { (Get-Item -LiteralPath $_).Length } | Measure-Object -Sum).Sum
if ($null -eq $totalBytes) {
$totalBytes = 0
}
$commitId = (& git rev-parse --short HEAD).Trim()
if ($LASTEXITCODE -ne 0) {
throw "Failed to read the current Git commit."
}
Write-Host ">>> Release plan"
Write-Host " Git commit: $commitId"
Write-Host " File count: $($files.Count)"
Write-Host " Source size: $([math]::Round($totalBytes / 1MB, 2)) MiB"
Write-Host " Download artifacts: $($artifactFiles.Count)"
Write-Host " Include download artifacts: $([bool]$IncludeDownloadArtifacts)"
Write-Host " Allowlist: $($releasePaths -join ', ')"
if ($DryRun) {
Write-Host ">>> DryRun complete: no archive created and no server connection attempted."
return
}
if (-not $HostName) {
throw "Provide a server through -HostName or DEPLOY_HOST."
}
if ($HostName -notmatch '^[A-Za-z0-9.:-]+$') {
throw "Host name contains unsupported characters."
}
$tarExe = Get-TarCommandPath
$tarDirectory = Split-Path $tarExe -Parent
$useGnuTar = Test-Path -LiteralPath (Join-Path $tarDirectory "gzip.exe") -PathType Leaf
$remoteTarget = "$User@$HostName"
$remoteDirLiteral = ConvertTo-ShellLiteral $RemoteDir
$remoteArchiveLiteral = ConvertTo-ShellLiteral $RemoteArchive
$skipDeployValue = if ($SkipDeploy) { "1" } else { "0" }
$skipBackupValue = if ($SkipBackup) { "1" } else { "0" }
$deployCommandTemplate = @'
set -euo pipefail
REMOTE_DIR=__REMOTE_DIR__
REMOTE_ARCHIVE=__REMOTE_ARCHIVE__
SKIP_DEPLOY=__SKIP_DEPLOY__
SKIP_BACKUP=__SKIP_BACKUP__
STAGING_DIR="$(mktemp -d /tmp/equipment-management-release.XXXXXX)"
BRANDING_BACKUP="$(mktemp -d /tmp/equipment-management-branding.XXXXXX)"
cleanup() {
rm -rf "$STAGING_DIR" "$BRANDING_BACKUP"
rm -f "$REMOTE_ARCHIVE"
}
trap cleanup EXIT
echo '>>> Validating and extracting release archive...'
tar -tzf "$REMOTE_ARCHIVE" >/dev/null
tar -xzf "$REMOTE_ARCHIVE" -C "$STAGING_DIR"
for required_path in \
backend/package.json \
frontend/package.json \
scripts/backup-equipment.sh \
deploy.sh \
docker-compose.yml; do
if [ ! -f "$STAGING_DIR/$required_path" ]; then
echo "Release archive is missing required file: $required_path" >&2
exit 1
fi
done
if [ -d "$REMOTE_DIR/frontend/public/branding" ]; then
cp -a "$REMOTE_DIR/frontend/public/branding/." "$BRANDING_BACKUP/"
fi
mkdir -p "$REMOTE_DIR"
rm -rf "$REMOTE_DIR/backend" "$REMOTE_DIR/frontend" "$REMOTE_DIR/scripts"
rm -f \
"$REMOTE_DIR/.env.example" \
"$REMOTE_DIR/AGENTS.md" \
"$REMOTE_DIR/BACKUP.md" \
"$REMOTE_DIR/Dockerfile" \
"$REMOTE_DIR/README.md" \
"$REMOTE_DIR/deploy.sh" \
"$REMOTE_DIR/docker-compose.yml"
cp -a "$STAGING_DIR/backend" "$REMOTE_DIR/backend"
cp -a "$STAGING_DIR/frontend" "$REMOTE_DIR/frontend"
cp -a "$STAGING_DIR/scripts" "$REMOTE_DIR/scripts"
if [ -d "$STAGING_DIR/downloads" ]; then
mkdir -p "$REMOTE_DIR/downloads"
cp -a "$STAGING_DIR/downloads/." "$REMOTE_DIR/downloads/"
fi
for root_file in \
.env.example \
AGENTS.md \
BACKUP.md \
Dockerfile \
README.md \
deploy.sh \
docker-compose.yml; do
if [ -f "$STAGING_DIR/$root_file" ]; then
cp -a "$STAGING_DIR/$root_file" "$REMOTE_DIR/$root_file"
fi
done
if find "$BRANDING_BACKUP" -mindepth 1 -print -quit | grep -q .; then
mkdir -p "$REMOTE_DIR/frontend/public/branding"
cp -a "$BRANDING_BACKUP/." "$REMOTE_DIR/frontend/public/branding/"
fi
sed -i 's/\r$//' "$REMOTE_DIR/deploy.sh" "$REMOTE_DIR/scripts/backup-equipment.sh"
chmod +x "$REMOTE_DIR/deploy.sh" "$REMOTE_DIR/scripts/backup-equipment.sh"
if [ "$SKIP_DEPLOY" = "1" ]; then
echo '>>> SkipDeploy enabled: allowlisted files synced without deployment'
elif [ "$SKIP_BACKUP" = "1" ]; then
echo '>>> WARNING: pre-deploy backup explicitly skipped'
SKIP_BACKUP=1 "$REMOTE_DIR/deploy.sh"
else
"$REMOTE_DIR/deploy.sh"
fi
'@
$deployCommand = $deployCommandTemplate
$deployCommand = $deployCommand.Replace("__REMOTE_DIR__", $remoteDirLiteral)
$deployCommand = $deployCommand.Replace("__REMOTE_ARCHIVE__", $remoteArchiveLiteral)
$deployCommand = $deployCommand.Replace("__SKIP_DEPLOY__", $skipDeployValue)
$deployCommand = $deployCommand.Replace("__SKIP_BACKUP__", $skipBackupValue)
$deployCommand = $deployCommand.Replace("`r`n", "`n")
try {
[System.IO.File]::WriteAllText(
$fileListPath,
(($files -join "`n") + "`n"),
$utf8NoBom
)
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
Write-Host ">>> Creating release archive..."
$tarArguments = @("-czf", $archivePath, "-T", $fileListPath)
$originalPath = $env:PATH
if ($useGnuTar) {
$env:PATH = "$tarDirectory;$env:PATH"
$tarArguments = @("--force-local") + $tarArguments
}
try {
Invoke-External -FilePath $tarExe -Arguments $tarArguments -Description "Create release archive"
} finally {
$env:PATH = $originalPath
}
if ($password) {
$plinkExe = Get-CommandPath "plink"
$pscpExe = Get-CommandPath "pscp"
$puttyBaseArgs = @("-batch", "-P", [string]$Port)
if ($hostKey) {
$puttyBaseArgs += @("-hostkey", $hostKey)
}
Write-Host ">>> Uploading and executing through PuTTY..."
$pscpArgs = $puttyBaseArgs + @("-pw", $password, $archivePath, "${remoteTarget}:$RemoteArchive")
Invoke-External -FilePath $pscpExe -Arguments $pscpArgs -Description "Upload release archive"
$plinkArgs = $puttyBaseArgs + @("-pw", $password, $remoteTarget, $deployCommand)
Invoke-External -FilePath $plinkExe -Arguments $plinkArgs -Description "Run remote deployment"
} else {
$sshExe = Get-CommandPath "ssh"
$scpExe = Get-CommandPath "scp"
$sshBaseArgs = @("-p", [string]$Port, "-o", "StrictHostKeyChecking=accept-new")
$scpBaseArgs = @("-P", [string]$Port, "-o", "StrictHostKeyChecking=accept-new")
Write-Host ">>> Uploading and executing through OpenSSH..."
Invoke-External -FilePath $scpExe -Arguments ($scpBaseArgs + @($archivePath, "${remoteTarget}:$RemoteArchive")) -Description "Upload release archive"
Invoke-External -FilePath $sshExe -Arguments ($sshBaseArgs + @($remoteTarget, $deployCommand)) -Description "Run remote deployment"
}
Write-Host ">>> Publish workflow completed"
} finally {
Remove-Item -LiteralPath $fileListPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
}