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 }