param( [string]$OutDir = "release", [string]$Version = "", [string]$NodeVersion = "", [switch]$NodeLocal, [switch]$WithPVE, [string]$NodeLocalDir = "", [switch]$UseGitTag, [switch]$Release, # [string[]]$Targets = @("linux/amd64", "windows/amd64"), [string[]]$Targets = @("linux/amd64"), [Alias("z")] [switch]$Zip ) $ErrorActionPreference = "Stop" $FrontendDistDir = "build/frontend/dist" $NodeLocalDefaultDir = "deploy/node" $NodeRemoteUrl = "https://github.com/Ithildur/Ithiltir-node.git" $NodeRepoSlug = "Ithildur/Ithiltir-node" $BuildChannel = "release" $NodeAssetManifest = [ordered]@{ "node_linux_amd64_sha256" = "linux/node_linux_amd64" "node_linux_arm64_sha256" = "linux/node_linux_arm64" "node_macos_arm64_sha256" = "macos/node_macos_arm64" "node_windows_amd64_sha256" = "windows/node_windows_amd64.exe" "node_windows_arm64_sha256" = "windows/node_windows_arm64.exe" "runner_windows_amd64_sha256" = "windows/runner_windows_amd64.exe" "runner_windows_arm64_sha256" = "windows/runner_windows_arm64.exe" } $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path function Get-GitTag { if ($env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME) { return $env:GITHUB_REF_NAME.Trim() } $tags = @(& git -C $repoRoot tag --points-at HEAD 2>$null | Where-Object { $_ }) if ($tags.Count -ne 1) { if ($tags.Count -gt 0) { [Console]::Error.WriteLine(($tags -join [Environment]::NewLine)) } throw "current commit must have exactly one git tag" } return $tags[0].Trim() } function Invoke-VersionTool { param( [Parameter(Mandatory = $true)] [string[]]$Arguments, [string[]]$InputLines = @() ) $go = Resolve-Go if ($InputLines.Count -gt 0 -or ($Arguments.Count -gt 0 -and $Arguments[0] -eq "latest")) { $inputText = "" if ($InputLines.Count -gt 0) { $inputText = ($InputLines -join [Environment]::NewLine) + [Environment]::NewLine } $output = @($inputText | & $go -C $repoRoot run ./cmd/releasever @Arguments) } else { $output = @(& $go -C $repoRoot run ./cmd/releasever @Arguments) } if ($LASTEXITCODE -ne 0) { throw ("version tool failed: go run ./cmd/releasever {0}" -f ($Arguments -join " ")) } return $output } function Get-VersionChannel { param( [Parameter(Mandatory = $true)] [string]$Value ) $output = Invoke-VersionTool -Arguments @("channel", $Value) if ($output.Count -eq 0) { return "" } return $output[0].Trim() } function Test-SemVer { param( [Parameter(Mandatory = $true)] [string]$Value ) try { Invoke-VersionTool -Arguments @("validate", $Value) | Out-Null return $true } catch { return $false } } function Resolve-BuildVersion { param( [string]$RawVersion ) if ($UseGitTag) { $RawVersion = Get-GitTag } if ($Release -and -not $UseGitTag) { throw "release mode requires -UseGitTag" } if ([string]::IsNullOrWhiteSpace($RawVersion)) { $RawVersion = "0.0.0-dev" } $RawVersion = $RawVersion.Trim() try { $script:BuildChannel = Get-VersionChannel -Value $RawVersion } catch { throw "version must be strict SemVer without a v prefix: $RawVersion" } return $RawVersion } function Get-LatestRemoteTag { param( [Parameter(Mandatory = $true)] [string]$RemoteUrl, [Parameter(Mandatory = $true)] [string]$Channel ) $refs = @(& git ls-remote --tags --refs --sort="v:refname" $RemoteUrl 2>$null) if ($LASTEXITCODE -ne 0) { $refs = @(& git ls-remote --tags --refs $RemoteUrl) if ($LASTEXITCODE -ne 0) { throw "failed to fetch tags from $RemoteUrl" } } $tags = @() foreach ($line in $refs) { $parts = $line -split "\s+" if ($parts.Count -lt 2) { continue } $tags += ($parts[1] -replace "^refs/tags/", "") } try { $latest = Invoke-VersionTool -Arguments @("latest", $Channel) -InputLines $tags } catch { throw "no valid Ithiltir-node $Channel tags found on $RemoteUrl" } if ($latest.Count -eq 0 -or [string]::IsNullOrWhiteSpace($latest[0])) { throw "no valid Ithiltir-node $Channel tags found on $RemoteUrl" } return $latest[0].Trim() } function Resolve-NodeVersion { param( [string]$RawVersion, [string]$LocalDir ) if ([string]::IsNullOrWhiteSpace($RawVersion)) { if (-not [string]::IsNullOrWhiteSpace($env:ITHILTIR_NODE_VERSION)) { $RawVersion = $env:ITHILTIR_NODE_VERSION } } if ([string]::IsNullOrWhiteSpace($RawVersion) -and -not [string]::IsNullOrWhiteSpace($LocalDir)) { throw "local node packaging requires -NodeVersion or ITHILTIR_NODE_VERSION" } if ([string]::IsNullOrWhiteSpace($RawVersion)) { if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "git is required to resolve latest Ithiltir-node release tag" } $RawVersion = Get-LatestRemoteTag -RemoteUrl $NodeRemoteUrl -Channel $BuildChannel } $RawVersion = $RawVersion.Trim() if (-not (Test-SemVer -Value $RawVersion)) { throw "node version must be strict SemVer without a v prefix: $RawVersion" } if ($Release) { $nodeChannel = Get-VersionChannel -Value $RawVersion if ($BuildChannel -eq "release" -and $nodeChannel -ne "release") { throw "node version channel ($nodeChannel) must match dash version channel ($BuildChannel)" } } return $RawVersion } function Download-File { param( [Parameter(Mandatory = $true)] [string]$Url, [Parameter(Mandatory = $true)] [string]$OutFile ) $outDir = Split-Path -Parent $OutFile if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -TimeoutSec 600 } function Download-NodeAsset { param( [Parameter(Mandatory = $true)] [string]$Version, [Parameter(Mandatory = $true)] [string]$Asset, [Parameter(Mandatory = $true)] [string]$OutFile ) $url = "https://github.com/{0}/releases/download/{1}/{2}" -f $NodeRepoSlug, $Version, $Asset Write-Host ("downloading node asset: {0}" -f $url) Download-File -Url $url -OutFile $OutFile } function Set-UnixExecutable { param( [Parameter(Mandatory = $true)] [string[]]$Paths ) $chmod = Get-Command chmod -ErrorAction SilentlyContinue if (-not $chmod) { return } foreach ($path in $Paths) { & $chmod.Source 755 $path if ($LASTEXITCODE -ne 0) { throw ("chmod failed: {0}" -f $path) } } } function Get-FileSHA256 { param( [Parameter(Mandatory = $true)] [string]$Path ) return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Set-NodeDeployPermissions { param( [Parameter(Mandatory = $true)] [string]$DeployDir ) Set-UnixExecutable -Paths @( (Join-Path $DeployDir "linux/node_linux_amd64"), (Join-Path $DeployDir "linux/node_linux_arm64"), (Join-Path $DeployDir "macos/node_macos_arm64") ) } function Prepare-RemoteNodeDeploy { param( [Parameter(Mandatory = $true)] [string]$Version, [Parameter(Mandatory = $true)] [string]$DeployDir ) if (Test-Path $DeployDir) { Remove-Item -Recurse -Force $DeployDir } New-Item -ItemType Directory -Path (Join-Path $DeployDir "linux") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $DeployDir "macos") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $DeployDir "windows") -Force | Out-Null $linuxAmd64 = Join-Path $DeployDir "linux/node_linux_amd64" $linuxArm64 = Join-Path $DeployDir "linux/node_linux_arm64" $macArm64 = Join-Path $DeployDir "macos/node_macos_arm64" Download-NodeAsset -Version $Version -Asset "Ithiltir-node-linux-amd64" -OutFile $linuxAmd64 Download-NodeAsset -Version $Version -Asset "Ithiltir-node-linux-arm64" -OutFile $linuxArm64 Download-NodeAsset -Version $Version -Asset "Ithiltir-node-macos-arm64" -OutFile $macArm64 Download-NodeAsset -Version $Version -Asset "Ithiltir-node-windows-amd64.exe" -OutFile (Join-Path $DeployDir "windows/node_windows_amd64.exe") Download-NodeAsset -Version $Version -Asset "Ithiltir-node-windows-arm64.exe" -OutFile (Join-Path $DeployDir "windows/node_windows_arm64.exe") Download-NodeAsset -Version $Version -Asset "Ithiltir-runner-windows-amd64.exe" -OutFile (Join-Path $DeployDir "windows/runner_windows_amd64.exe") Download-NodeAsset -Version $Version -Asset "Ithiltir-runner-windows-arm64.exe" -OutFile (Join-Path $DeployDir "windows/runner_windows_arm64.exe") Set-NodeDeployPermissions -DeployDir $DeployDir } function Copy-LocalNodeAsset { param( [Parameter(Mandatory = $true)] [string[]]$Sources, [Parameter(Mandatory = $true)] [string]$OutFile ) foreach ($source in $Sources) { if (Test-Path $source -PathType Leaf) { $outDir = Split-Path -Parent $OutFile if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } Copy-Item -Force $source $OutFile return } } throw ("local node asset not found. tried: {0}" -f ($Sources -join ", ")) } function Prepare-LocalNodeDeploy { param( [Parameter(Mandatory = $true)] [string]$SourceDir, [Parameter(Mandatory = $true)] [string]$DeployDir ) if (-not (Test-Path $SourceDir -PathType Container)) { throw ("local node deploy directory not found: {0}" -f $SourceDir) } if (Test-Path $DeployDir) { Remove-Item -Recurse -Force $DeployDir } New-Item -ItemType Directory -Path (Join-Path $DeployDir "linux") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $DeployDir "macos") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $DeployDir "windows") -Force | Out-Null Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "linux/node_linux_amd64"), (Join-Path $SourceDir "linux/Ithiltir-node-linux-amd64"), (Join-Path $SourceDir "Ithiltir-node-linux-amd64")) -OutFile (Join-Path $DeployDir "linux/node_linux_amd64") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "linux/node_linux_arm64"), (Join-Path $SourceDir "linux/Ithiltir-node-linux-arm64"), (Join-Path $SourceDir "Ithiltir-node-linux-arm64")) -OutFile (Join-Path $DeployDir "linux/node_linux_arm64") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "macos/node_macos_arm64"), (Join-Path $SourceDir "macos/Ithiltir-node-macos-arm64"), (Join-Path $SourceDir "Ithiltir-node-macos-arm64")) -OutFile (Join-Path $DeployDir "macos/node_macos_arm64") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "windows/node_windows_amd64.exe"), (Join-Path $SourceDir "windows/Ithiltir-node-windows-amd64.exe"), (Join-Path $SourceDir "Ithiltir-node-windows-amd64.exe")) -OutFile (Join-Path $DeployDir "windows/node_windows_amd64.exe") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "windows/node_windows_arm64.exe"), (Join-Path $SourceDir "windows/Ithiltir-node-windows-arm64.exe"), (Join-Path $SourceDir "Ithiltir-node-windows-arm64.exe")) -OutFile (Join-Path $DeployDir "windows/node_windows_arm64.exe") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "windows/runner_windows_amd64.exe"), (Join-Path $SourceDir "windows/Ithiltir-runner-windows-amd64.exe"), (Join-Path $SourceDir "Ithiltir-runner-windows-amd64.exe")) -OutFile (Join-Path $DeployDir "windows/runner_windows_amd64.exe") Copy-LocalNodeAsset -Sources @((Join-Path $SourceDir "windows/runner_windows_arm64.exe"), (Join-Path $SourceDir "windows/Ithiltir-runner-windows-arm64.exe"), (Join-Path $SourceDir "Ithiltir-runner-windows-arm64.exe")) -OutFile (Join-Path $DeployDir "windows/runner_windows_arm64.exe") Set-NodeDeployPermissions -DeployDir $DeployDir } function Resolve-RepoPath { param( [Parameter(Mandatory = $true)] [string]$Path ) if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) } return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) } $outRoot = Resolve-RepoPath $OutDir New-Item -ItemType Directory -Path $outRoot -Force | Out-Null if ($NodeLocal -and [string]::IsNullOrWhiteSpace($NodeLocalDir)) { $NodeLocalDir = $NodeLocalDefaultDir } function Resolve-Go { $go = Get-Command go -ErrorAction SilentlyContinue if (-not $go) { throw "go is required to build dash" } return $go.Source } function Get-DashBinaryPath { param( [string]$Os, [string]$Arch, [string]$DistRoot ) $osDir = $Os if ($Os -eq "darwin") { $osDir = "macos" } $binName = "dash_{0}" -f $Arch if ($Os -eq "windows") { $binName = "{0}.exe" -f $binName } return Join-Path $DistRoot (Join-Path $osDir $binName) } function Build-DashBinary { param( [Parameter(Mandatory = $true)] [string]$Os, [Parameter(Mandatory = $true)] [string]$Arch, [Parameter(Mandatory = $true)] [string]$DistRoot, [Parameter(Mandatory = $true)] [string]$GoCmd, [Parameter(Mandatory = $true)] [string]$Ldflags ) $output = Get-DashBinaryPath -Os $Os -Arch $Arch -DistRoot $DistRoot $outputDir = Split-Path -Parent $output New-Item -ItemType Directory -Path $outputDir -Force | Out-Null Write-Host ("building dash for {0}/{1}: {2}" -f $Os, $Arch, $output) $oldGoos = $env:GOOS $oldGoarch = $env:GOARCH $oldCgo = $env:CGO_ENABLED try { $env:GOOS = $Os $env:GOARCH = $Arch $env:CGO_ENABLED = "0" & $GoCmd -C $repoRoot build -trimpath -ldflags $Ldflags -o $output ./cmd/dash if ($LASTEXITCODE -ne 0) { throw ("go build failed for {0}/{1} (exit {2})" -f $Os, $Arch, $LASTEXITCODE) } } finally { $env:GOOS = $oldGoos $env:GOARCH = $oldGoarch $env:CGO_ENABLED = $oldCgo } } $buildVersion = Resolve-BuildVersion -RawVersion $Version $nodeBuildVersion = Resolve-NodeVersion -RawVersion $NodeVersion -LocalDir $NodeLocalDir $ldflags = "-s -w -X dash/internal/version.Current=$buildVersion -X dash/internal/version.BundledNode=$nodeBuildVersion" Write-Host ("dash version: {0}" -f $buildVersion) Write-Host ("dash channel: {0}" -f $BuildChannel) Write-Host ("node version: {0}" -f $nodeBuildVersion) if (-not [string]::IsNullOrWhiteSpace($NodeLocalDir)) { Write-Host ("node source: {0}" -f (Resolve-RepoPath $NodeLocalDir)) } $goCmd = Resolve-Go $frontendScript = Join-Path $repoRoot "scripts/build_frontend.ps1" $frontendDistPath = Resolve-RepoPath $FrontendDistDir $targetList = @() foreach ($rawTarget in $Targets) { $targetList += $rawTarget.Split(",", [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_.Trim() } | Where-Object { $_ } } if ($targetList.Count -eq 0) { throw "no valid targets provided" } foreach ($t in $targetList) { $parts = $t.Split("/", 2) if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { throw "invalid target: $t (expected os/arch)" } } Push-Location $repoRoot try { if (-not (Test-Path $frontendScript)) { throw ("frontend build script not found: {0}" -f $frontendScript) } & $frontendScript -OutDir $FrontendDistDir -Version $buildVersion if ($LASTEXITCODE -ne 0) { throw ("frontend build failed (exit {0})" -f $LASTEXITCODE) } if (-not (Test-Path $frontendDistPath)) { throw ("frontend build output not found: {0}" -f $frontendDistPath) } $distRoot = Join-Path $repoRoot "build" $nodeDeployPath = Join-Path $distRoot "node-deploy" if ([string]::IsNullOrWhiteSpace($NodeLocalDir)) { Prepare-RemoteNodeDeploy -Version $nodeBuildVersion -DeployDir $nodeDeployPath } else { Prepare-LocalNodeDeploy -SourceDir (Resolve-RepoPath $NodeLocalDir) -DeployDir $nodeDeployPath } if ($WithPVE) { $pveManifest = @("format_version=1", "node_version=$nodeBuildVersion") foreach ($pveArch in @("amd64", "arm64")) { $pveFile = "pve_cache_linux_$pveArch" $pveOutput = Join-Path $nodeDeployPath "linux/$pveFile" if ([string]::IsNullOrWhiteSpace($NodeLocalDir)) { Download-NodeAsset -Version $nodeBuildVersion -Asset "Ithiltir-pve-cache-linux-$pveArch" -OutFile $pveOutput } else { $pveSource = Resolve-RepoPath $NodeLocalDir Copy-LocalNodeAsset -Sources @((Join-Path $pveSource "linux/$pveFile"), (Join-Path $pveSource "Ithiltir-pve-cache-linux-$pveArch")) -OutFile $pveOutput } $pveManifest += "${pveArch}_sha256=$(Get-FileSHA256 -Path $pveOutput)" Set-UnixExecutable -Paths @($pveOutput) } [System.IO.File]::WriteAllText((Join-Path $nodeDeployPath "linux/pve-cache.env"), ($pveManifest -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false)) } $nodeAssetSHA256 = @{} foreach ($field in $NodeAssetManifest.Keys) { $nodeAssetSHA256[$field] = Get-FileSHA256 -Path (Join-Path $nodeDeployPath $NodeAssetManifest[$field]) } foreach ($t in $targetList) { $parts = $t.Split("/", 2) if ($parts.Count -ne 2) { throw "invalid target: $t (expected os/arch)" } $os = $parts[0] $arch = $parts[1] Build-DashBinary -Os $os -Arch $arch -DistRoot $distRoot -GoCmd $goCmd -Ldflags $ldflags $buildRoot = Join-Path $outRoot "build" New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null $pkgRoot = Join-Path $buildRoot "Ithiltir-dash" if (Test-Path $pkgRoot) { Remove-Item -Recurse -Force $pkgRoot } New-Item -ItemType Directory -Path $pkgRoot | Out-Null New-Item -ItemType Directory -Path (Join-Path $pkgRoot "bin") | Out-Null New-Item -ItemType Directory -Path (Join-Path $pkgRoot "configs") | Out-Null New-Item -ItemType Directory -Path (Join-Path $pkgRoot "logs") | Out-Null $exeName = "dash" if ($os -eq "windows") { $exeName = "dash.exe" } $exePath = Join-Path $pkgRoot ("bin/{0}" -f $exeName) $sourceExe = Get-DashBinaryPath -Os $os -Arch $arch -DistRoot $distRoot if (-not (Test-Path $sourceExe)) { throw ("dash build output not found: {0}" -f $sourceExe) } Copy-Item -Force $sourceExe $exePath $releaseMetadata = [string[]]@( "format_version=1" "dash_version=$buildVersion" "node_version=$nodeBuildVersion" "target_os=$os" "target_arch=$arch" ) foreach ($field in $NodeAssetManifest.Keys) { $releaseMetadata += "{0}={1}" -f $field, $nodeAssetSHA256[$field] } $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText( (Join-Path $pkgRoot "release.env"), (($releaseMetadata -join "`n") + "`n"), $utf8NoBom ) Copy-Item -Force (Join-Path $repoRoot "configs/config.example.yaml") (Join-Path $pkgRoot "configs/config.example.yaml") Copy-Item -Recurse -Force $frontendDistPath (Join-Path $pkgRoot "dist") Copy-Item -Recurse -Force $nodeDeployPath (Join-Path $pkgRoot "deploy") Copy-Item -Force (Join-Path $repoRoot "install_dash_linux.sh") $pkgRoot Copy-Item -Force (Join-Path $repoRoot "update_dash_linux.sh") $pkgRoot if ($os -eq "linux") { Set-UnixExecutable -Paths @( $exePath, (Join-Path $pkgRoot "install_dash_linux.sh"), (Join-Path $pkgRoot "update_dash_linux.sh") ) } if ($Zip) { $zipPath = Join-Path $outRoot ("Ithiltir_dash_{0}_{1}.zip" -f $os, $arch) if (Test-Path $zipPath) { Remove-Item -Force $zipPath } Compress-Archive -Path $pkgRoot -DestinationPath $zipPath -Force } } } finally { Pop-Location } Write-Host ("done. output: {0}" -f $outRoot)