Scert/build/build-release.ps1

487 lines
18 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[CmdletBinding()]
param(
[string]$BackendDirectory = "",
[string]$OutputDirectory = "",
[string]$DesktopDirectory = "",
[string]$DeployDirectory = "",
[ValidatePattern("^V[0-9]+\.[0-9]+(?:\.[0-9]+)?$")]
[string]$Version = "V0.1.0",
[ValidatePattern("^[0-9]{8}$")]
[string]$ReleaseDate = (Get-Date -Format "yyyyMMdd"),
[ValidateSet("windows-amd64", "linux-amd64")]
[string[]]$Target = @("windows-amd64", "linux-amd64"),
[switch]$SkipDesktop
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Definition
if ([string]::IsNullOrWhiteSpace($BackendDirectory)) {
$BackendDirectory = Join-Path $scriptDirectory "..\backend"
}
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $scriptDirectory "release"
}
if ([string]::IsNullOrWhiteSpace($DesktopDirectory)) {
$DesktopDirectory = Join-Path $scriptDirectory "..\desktop"
}
if ([string]::IsNullOrWhiteSpace($DeployDirectory)) {
$DeployDirectory = Join-Path $scriptDirectory "..\deploy"
}
function Resolve-SafeDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Label
)
if ([string]::IsNullOrWhiteSpace($Path)) {
throw "$Label 不能为空。"
}
return [System.IO.Path]::GetFullPath($Path)
}
function Invoke-GoBuild {
param(
[Parameter(Mandatory = $true)]
[string]$OperatingSystem,
[Parameter(Mandatory = $true)]
[string]$Architecture,
[Parameter(Mandatory = $true)]
[string]$Destination,
[Parameter(Mandatory = $true)]
[string]$Package
)
$env:GOOS = $OperatingSystem
$env:GOARCH = $Architecture
$env:CGO_ENABLED = "0"
& go build -trimpath -o $Destination $Package
if ($LASTEXITCODE -ne 0) {
throw "构建 $OperatingSystem/$Architecture 的 $Package 失败。"
}
}
function Get-HostDesktopTarget {
if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[System.Runtime.InteropServices.OSPlatform]::Windows
)) {
return "windows-amd64"
}
if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[System.Runtime.InteropServices.OSPlatform]::Linux
)) {
return "linux-amd64"
}
return ""
}
function Invoke-DesktopBuild {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectDirectory,
[Parameter(Mandatory = $true)]
[string]$TargetName,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (-not (Get-Command wails -ErrorAction SilentlyContinue)) {
throw "未找到 Wails CLI,请先安装 Wails v2."
}
if (-not (Test-Path -LiteralPath (Join-Path $ProjectDirectory "wails.json") -PathType Leaf)) {
throw "桌面项目中没有找到 wails.json:$ProjectDirectory"
}
Write-Host "正在构建 $TargetName 的 Scert 桌面版……"
$desktopGoOS = $env:GOOS
$desktopGoArch = $env:GOARCH
$desktopCGOEnabled = $env:CGO_ENABLED
Push-Location $ProjectDirectory
try {
$env:GOOS = ""
$env:GOARCH = ""
if ($TargetName.StartsWith("linux-")) {
$env:CGO_ENABLED = "1"
}
& wails build -trimpath -nopackage -platform ($TargetName.Replace("-", "/")) -o "ScertDesktop"
if ($LASTEXITCODE -ne 0) {
throw "构建 $TargetName 的 Scert 桌面版失败。"
}
$builtExtension = if ($TargetName.StartsWith("windows-")) { ".exe" } else { "" }
$builtDirectory = Join-Path (Join-Path $ProjectDirectory "build") "bin"
$builtPath = Join-Path $builtDirectory ("ScertDesktop" + $builtExtension)
if (-not (Test-Path -LiteralPath $builtPath -PathType Leaf)) {
throw "Wails 构建完成但没有找到桌面产物:$builtPath"
}
Copy-Item -LiteralPath $builtPath -Destination $Destination -Force
}
finally {
Pop-Location
$env:GOOS = $desktopGoOS
$env:GOARCH = $desktopGoArch
$env:CGO_ENABLED = $desktopCGOEnabled
}
}
function Write-Utf8NoBom {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Content
)
[System.IO.File]::WriteAllText(
$Path,
$Content,
[System.Text.UTF8Encoding]::new($false)
)
}
function Get-RelativeReleasePath {
param(
[Parameter(Mandatory = $true)]
[string]$BaseDirectory,
[Parameter(Mandatory = $true)]
[string]$Path
)
$base = $BaseDirectory.TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
if (-not $Path.StartsWith($base + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "发布文件不在预期目录内:$Path"
}
return $Path.Substring($base.Length + 1).Replace("\", "/")
}
function Write-TargetChecksums {
param(
[Parameter(Mandatory = $true)]
[string]$TargetDirectory
)
$checksumPath = Join-Path $TargetDirectory "SHA256SUMS"
$lines = [System.Collections.Generic.List[string]]::new()
Get-ChildItem -LiteralPath $TargetDirectory -Recurse -File |
Where-Object { $_.FullName -ne $checksumPath } |
Sort-Object FullName |
ForEach-Object {
$relative = Get-RelativeReleasePath -BaseDirectory $TargetDirectory -Path $_.FullName
$hash = Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256
$lines.Add("$($hash.Hash.ToLowerInvariant()) $relative")
}
Write-Utf8NoBom -Path $checksumPath -Content (($lines -join "`n") + "`n")
return $checksumPath
}
function Invoke-ReleaseArchive {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination,
[Parameter(Mandatory = $true)]
[ValidateSet("tar.gz", "zip")]
[string]$Format,
[string[]]$Executable = @()
)
$arguments = @(
"run",
"-trimpath",
"./cmd/scert-release",
"--source", $Source,
"--output", $Destination,
"--format", $Format
)
foreach ($path in $Executable) {
$arguments += @("--executable", $path)
}
$archiveGoOS = $env:GOOS
$archiveGoArch = $env:GOARCH
$archiveCGOEnabled = $env:CGO_ENABLED
try {
$env:GOOS = ""
$env:GOARCH = ""
$env:CGO_ENABLED = "0"
& go @arguments
if ($LASTEXITCODE -ne 0) {
throw "生成发布归档失败:$Destination"
}
}
finally {
$env:GOOS = $archiveGoOS
$env:GOARCH = $archiveGoArch
$env:CGO_ENABLED = $archiveCGOEnabled
}
}
function Test-ReleasePaths {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string[]]$Binaries,
[Parameter(Mandatory = $true)]
[string[]]$ForbiddenPaths
)
$arguments = @("run", "-trimpath", "./cmd/scert-release", "--verify-only", "--source", $Source)
foreach ($binary in $Binaries) {
$arguments += @("--binary", $binary)
}
foreach ($path in ($ForbiddenPaths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) {
$arguments += @("--forbid-path", $path)
}
$checkGoOS = $env:GOOS
$checkGoArch = $env:GOARCH
$checkCGOEnabled = $env:CGO_ENABLED
try {
$env:GOOS = ""
$env:GOARCH = ""
$env:CGO_ENABLED = "0"
& go @arguments
if ($LASTEXITCODE -ne 0) {
throw "发布路径检查未通过,停止归档。"
}
}
finally {
$env:GOOS = $checkGoOS
$env:GOARCH = $checkGoArch
$env:CGO_ENABLED = $checkCGOEnabled
}
}
$backend = Resolve-SafeDirectory -Path $BackendDirectory -Label "后端目录"
$output = Resolve-SafeDirectory -Path $OutputDirectory -Label "输出目录"
$desktop = Resolve-SafeDirectory -Path $DesktopDirectory -Label "桌面项目目录"
$deploy = Resolve-SafeDirectory -Path $DeployDirectory -Label "部署目录"
$frontend = Resolve-SafeDirectory -Path (Join-Path $scriptDirectory "..\frontend") -Label "前端目录"
if (-not (Test-Path -LiteralPath (Join-Path $backend "go.mod") -PathType Leaf)) {
throw "后端目录中没有找到 go.mod:$backend"
}
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "未找到 Go 工具链,请先安装 Go 并加入 PATH。"
}
$npmCommand = if ($env:OS -eq "Windows_NT") { "npm.cmd" } else { "npm" }
if (-not (Get-Command $npmCommand -ErrorAction SilentlyContinue)) {
throw "未找到 npm 工具链,请先安装 Node.js 并加入 PATH。"
}
if (-not (Test-Path -LiteralPath (Join-Path $frontend "package.json") -PathType Leaf)) {
throw "前端目录中没有找到 package.json:$frontend"
}
foreach ($requiredDeploymentFile in @(
"install-linux.sh",
"install-windows.ps1",
"README.md",
"systemd\Scert.service",
"systemd\Scert-admin.service",
"systemd\scert-nginx.socket",
"systemd\scert-nginx.service",
"nginx\policy.json.example",
"nginx\Nginx辅助服务部署说明.md",
"systemd\Scert服务管理说明.md",
"systemd\Scert-admin服务管理说明.md"
)) {
$requiredPath = Join-Path $deploy $requiredDeploymentFile
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "部署目录缺少发布文件:$requiredPath"
}
}
$releaseName = "Scert-$ReleaseDate-$Version"
$releaseDirectory = Join-Path $output $releaseName
if (Test-Path -LiteralPath $releaseDirectory) {
throw "发布目录已存在,拒绝覆盖:$releaseDirectory"
}
if (@($Target | Select-Object -Unique).Count -ne $Target.Count) {
throw "目标平台不能重复。"
}
foreach ($item in $Target) {
$suffix = if ($item.StartsWith("linux-")) { "tar.gz" } else { "zip" }
$archivePath = Join-Path $output "$releaseName-$item.$suffix"
if (Test-Path -LiteralPath $archivePath) {
throw "发布归档已存在,拒绝覆盖:$archivePath"
}
}
$sourceCommit = (& git -C $backend rev-parse HEAD)
if ($LASTEXITCODE -ne 0) {
throw "无法取得源码提交,不能生成可追溯发布包。"
}
$sourceChanges = @(& git -C $backend status --porcelain --untracked-files=no)
if ($LASTEXITCODE -ne 0) {
throw "无法检查源码工作区状态。"
}
$sourceDirty = $sourceChanges.Count -gt 0
if ($sourceDirty) {
Write-Warning "存在未提交的已跟踪文件改动,清单将标记 source_dirty=true,不能作为正式发布包。"
}
New-Item -ItemType Directory -Path $releaseDirectory | Out-Null
$frontendDistribution = Join-Path $releaseDirectory "web-build"
Write-Host "正在构建 Scert Web 前端……"
Push-Location $frontend
try {
& $npmCommand run build -- --outDir $frontendDistribution --emptyOutDir false
if ($LASTEXITCODE -ne 0) {
throw "构建 Scert Web 前端失败。"
}
}
finally {
Pop-Location
}
if (-not (Test-Path -LiteralPath (Join-Path $frontendDistribution "index.html") -PathType Leaf)) {
throw "前端构建完成但没有找到 index.html:$frontendDistribution"
}
$oldGoOS = $env:GOOS
$oldGoArch = $env:GOARCH
$oldCGOEnabled = $env:CGO_ENABLED
$manifestArtifacts = [System.Collections.Generic.List[object]]::new()
$hostDesktopTarget = Get-HostDesktopTarget
Push-Location $backend
try {
$goVersion = (& go version)
if ($LASTEXITCODE -ne 0) {
throw "无法取得实际构建使用的 Go 工具链版本。"
}
$goEnvironmentJSON = (& go env -json GOROOT GOPATH GOMODCACHE GOCACHE GOTMPDIR)
if ($LASTEXITCODE -ne 0) {
throw "无法取得本机构建路径,拒绝发布。"
}
$goEnvironment = ($goEnvironmentJSON -join "`n") | ConvertFrom-Json
$forbiddenPaths = @(
(Split-Path -Parent $scriptDirectory), $backend, $desktop, $frontend, $deploy, $output,
$env:USERPROFILE, $env:HOME, [System.IO.Path]::GetTempPath(),
$goEnvironment.GOROOT, $goEnvironment.GOMODCACHE, $goEnvironment.GOCACHE, $goEnvironment.GOTMPDIR
) + $goEnvironment.GOPATH.Split([System.IO.Path]::PathSeparator)
if ($env:OS -eq "Windows_NT") {
$forbiddenPaths += @(
(Split-Path -Parent (Get-Command go).Source),
(Split-Path -Parent (Get-Command node).Source)
)
}
foreach ($item in $Target) {
$parts = $item.Split("-", 2)
$goos = $parts[0]
$goarch = $parts[1]
$targetDirectory = Join-Path $releaseDirectory $item
New-Item -ItemType Directory -Path $targetDirectory | Out-Null
$extension = if ($goos -eq "windows") { ".exe" } else { "" }
$scertPath = Join-Path $targetDirectory ("scert" + $extension)
$adminPath = Join-Path $targetDirectory ("scert-admin" + $extension)
$binaries = @(("scert" + $extension), ("scert-admin" + $extension))
Write-Host "正在构建 $goos/$goarch 的 Scert……"
Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $scertPath -Package "./cmd/scert"
Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $adminPath -Package "./cmd/scert-admin"
if ($goos -eq "linux") {
Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination (Join-Path $targetDirectory "scert-nginx") -Package "./cmd/scert-nginx"
$binaries += "scert-nginx"
}
$webPath = Join-Path $targetDirectory "web"
New-Item -ItemType Directory -Path $webPath -Force | Out-Null
Copy-Item -Path (Join-Path $frontendDistribution "*") -Destination $webPath -Recurse -Force
Copy-Item -LiteralPath (Join-Path $deploy "README.md") -Destination (Join-Path $targetDirectory "部署说明.md")
if ($goos -eq "linux") {
Copy-Item -LiteralPath (Join-Path $deploy "install-linux.sh") -Destination (Join-Path $targetDirectory "install-linux.sh")
Copy-Item -LiteralPath (Join-Path $deploy "systemd") -Destination (Join-Path $targetDirectory "systemd") -Recurse
Copy-Item -LiteralPath (Join-Path $deploy "nginx") -Destination (Join-Path $targetDirectory "nginx") -Recurse
}
else {
Copy-Item -LiteralPath (Join-Path $deploy "install-windows.ps1") -Destination (Join-Path $targetDirectory "install-windows.ps1")
}
if (-not $SkipDesktop) {
if ($item -eq $hostDesktopTarget) {
$desktopExtension = if ($goos -eq "windows") { ".exe" } else { "" }
$desktopPath = Join-Path $targetDirectory ("ScertDesktop" + $desktopExtension)
Invoke-DesktopBuild -ProjectDirectory $desktop -TargetName $item -Destination $desktopPath
$binaries += ("ScertDesktop" + $desktopExtension)
$file = Get-Item -LiteralPath $desktopPath
$hash = Get-FileHash -LiteralPath $desktopPath -Algorithm SHA256
$manifestArtifacts.Add([ordered]@{
target = $item
type = "desktop"
file = Get-RelativeReleasePath -BaseDirectory $releaseDirectory -Path $file.FullName
size = $file.Length
sha256 = $hash.Hash.ToLowerInvariant()
})
}
else {
Write-Warning "当前主机不能直接构建 $item 的 Wails 桌面版;请在对应平台主机上执行同一脚本。"
}
}
$checkedPaths = @($forbiddenPaths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
Test-ReleasePaths -Source $targetDirectory -Binaries $binaries -ForbiddenPaths $checkedPaths
Write-TargetChecksums -TargetDirectory $targetDirectory | Out-Null
foreach ($file in (Get-ChildItem -LiteralPath $targetDirectory -Recurse -File | Sort-Object FullName)) {
$hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256
$manifestArtifacts.Add([ordered]@{
target = $item
type = "package_file"
file = Get-RelativeReleasePath -BaseDirectory $releaseDirectory -Path $file.FullName
size = $file.Length
sha256 = $hash.Hash.ToLowerInvariant()
})
}
if ($goos -eq "linux") {
$archivePath = Join-Path $output "$releaseName-$item.tar.gz"
Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "tar.gz" -Executable (@("install-linux.sh") + $binaries)
}
else {
$archivePath = Join-Path $output "$releaseName-$item.zip"
Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "zip"
}
$archive = Get-Item -LiteralPath $archivePath
$archiveHash = Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
$manifestArtifacts.Add([ordered]@{
target = $item
type = "archive"
file = $archive.Name
size = $archive.Length
sha256 = $archiveHash.Hash.ToLowerInvariant()
})
}
}
finally {
Pop-Location
$env:GOOS = $oldGoOS
$env:GOARCH = $oldGoArch
$env:CGO_ENABLED = $oldCGOEnabled
}
$manifest = [ordered]@{
product = "Scert"
release_date = $ReleaseDate
version = $Version
source_commit = $sourceCommit.Trim()
source_dirty = $sourceDirty
go_toolchain = $goVersion.Trim()
trimpath = $true
local_path_check = "passed"
targets = @($Target)
desktop_requested = -not [bool]$SkipDesktop
generated_at = [DateTimeOffset]::UtcNow.ToString("o")
artifacts = $manifestArtifacts
}
$manifestPath = Join-Path $releaseDirectory "manifest.json"
Write-Utf8NoBom -Path $manifestPath -Content (($manifest | ConvertTo-Json -Depth 6) + "`n")
Write-Host "发布构建完成:$releaseDirectory"
Write-Host "校验清单:$manifestPath"