param( [string]$Version = '0.76.0-rc.hardware1', [string]$OutputDirectory = '.deploy-artifacts' ) $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $projectRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path $outputRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot $OutputDirectory)) if (-not $outputRoot.StartsWith($projectRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'OutputDirectory must resolve inside the project workspace' } if ($Version -notmatch '^[0-9A-Za-z][0-9A-Za-z._-]{0,79}$') { throw 'Version contains unsupported characters' } New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null $bundleName = "data-terminal-cloud-$Version" $stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N')) $payloadRoot = Join-Path $stagingRoot $bundleName New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null try { $candidatePaths = @(& git -c core.quotePath=false ls-files --cached --others --exclude-standard -- ` cmd internal migrations openapi web deploy scripts test docs compose.yaml go.mod go.sum README.md CHANGELOG.md .env.example .dockerignore) if ($LASTEXITCODE -ne 0) { throw 'git file enumeration failed' } $candidatePaths = $candidatePaths | Where-Object { $_ -and $_ -notmatch '(^|/)(node_modules|dist|coverage|runtime|release)(/|$)' -and $_ -notmatch '(^|/)\.env$' -and $_ -notmatch '\.(pem|key|p12|pfx)$' } | Sort-Object -Unique if ($candidatePaths.Count -lt 20) { throw 'deployment file set is unexpectedly small' } foreach ($relativePath in $candidatePaths) { $sourcePath = [IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath)) if (-not $sourcePath.StartsWith($projectRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { throw "Unsafe or missing deployment source path: $relativePath" } $targetPath = Join-Path $payloadRoot $relativePath New-Item -ItemType Directory -Force -Path (Split-Path -Parent $targetPath) | Out-Null Copy-Item -LiteralPath $sourcePath -Destination $targetPath } $sourceCommit = (& git rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-f]{40}$') { throw 'source commit could not be resolved' } $statusLines = @(& git status --porcelain=v1) if ($LASTEXITCODE -ne 0) { throw 'working tree status could not be resolved' } $files = Get-ChildItem -LiteralPath $payloadRoot -File -Recurse | Sort-Object FullName | ForEach-Object { [ordered]@{ path = $_.FullName.Substring($payloadRoot.Length + 1).Replace('\','/') size_bytes = $_.Length sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } } $manifest = [ordered]@{ schema_version = 'server-deployment-bundle-v1' candidate_version = $Version generated_at = [DateTime]::UtcNow.ToString('o') source_commit = $sourceCommit working_tree_dirty = $statusLines.Count -gt 0 schema_migration_target = 58 hardware_runtime = 'volc_hardware_agent' files = @($files) } $manifestPath = Join-Path $payloadRoot 'deploy-manifest.json' [IO.File]::WriteAllText($manifestPath, (($manifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) $checksumLines = Get-ChildItem -LiteralPath $payloadRoot -File -Recurse | Where-Object Name -ne 'SHA256SUMS' | Sort-Object FullName | ForEach-Object { $relative = $_.FullName.Substring($payloadRoot.Length + 1).Replace('\','/') "{0} {1}" -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $relative } [IO.File]::WriteAllLines((Join-Path $payloadRoot 'SHA256SUMS'), [string[]]$checksumLines, [Text.UTF8Encoding]::new($false)) $archivePath = Join-Path $outputRoot ($bundleName + '.zip') if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath -Force } Compress-Archive -LiteralPath $payloadRoot -DestinationPath $archivePath -CompressionLevel Optimal $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() "$archiveHash $([IO.Path]::GetFileName($archivePath))" | Set-Content -LiteralPath ($archivePath + '.sha256') -Encoding ascii [ordered]@{ archive = $archivePath; sha256 = $archiveHash; files = $files.Count; source_commit = $sourceCommit; working_tree_dirty = $statusLines.Count -gt 0 } | ConvertTo-Json -Compress } finally { $resolvedStaging = [IO.Path]::GetFullPath($stagingRoot) if ($resolvedStaging.StartsWith($outputRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolvedStaging)) { Remove-Item -LiteralPath $resolvedStaging -Recurse -Force } }