CultLib/scripts/build-unity-package.ps1

271 lines
16 KiB
PowerShell
Raw Normal View History

2026-07-10 03:34:49 +01:00
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib",
[string] $NuGetConfig = "",
2026-08-18 23:44:51 +02:00
[switch] $NoRestore,
[switch] $UpdateTemplate
2026-07-10 03:34:49 +01:00
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
# The CultMath byte gate below needs a git checkout: ContinuousIntegrationBuild maps the repo root to /_/
# through the SDK's git source root, so in a tree that is not the top of a git checkout (git archive, a
# source zip, an empty .git) the DLL names the absolute obj path and its bytes change with the directory.
# Git itself answers, so a worktree, where .git is a file, passes.
$gitErrorAction = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$gitTop = & git -C $repoRoot rev-parse --show-toplevel 2>$null
$gitExit = $LASTEXITCODE
$ErrorActionPreference = $gitErrorAction
$isGitTop = $gitExit -eq 0 -and $gitTop -and
([System.IO.Path]::GetFullPath("$gitTop").TrimEnd('\', '/') -eq [System.IO.Path]::GetFullPath($repoRoot).TrimEnd('\', '/'))
if (-not $isGitTop) {
throw "$repoRoot is not the top of a git checkout (git rev-parse --show-toplevel: '$gitTop', exit $gitExit), so CultMath.dll would carry this directory's path and fail the CultMath byte gate. Build from a git clone or worktree, not an archive or source zip."
}
2026-07-10 03:34:49 +01:00
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib"
# Every managed publish the package is assembled from, in the order $publishedByName fills (last wins).
# This list is the one owner of publish-root identity: the clean step, the publish loop, the assembly
# lookup, the CultMath byte gate and the semver check's --api-refs all read it.
$managedPublishes = @(
@{ Project = "src\GameCult.Mesh\GameCult.Mesh.csproj"; Root = "artifacts\unity-publish"; Extra = @(); Label = "CultLib" },
@{ Project = "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj"; Root = "artifacts\unity-quic-publish"; Extra = @(); Label = "CultLib native QUIC managed" },
@{ Project = "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj"; Root = "artifacts\unity-websocket-publish"; Extra = @("-f", "netstandard2.1"); Label = "CultLib WebSocket" }
)
foreach ($publish in $managedPublishes) {
$publish.Root = Join-Path $repoRoot $publish.Root
}
$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native"
2026-07-10 03:34:49 +01:00
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
$OutputDirectory
} else {
Join-Path $repoRoot $OutputDirectory
}
$pluginRoot = Join-Path $outputRoot "Runtime\Plugins"
foreach ($root in @($managedPublishes | ForEach-Object { $_.Root }) + $outputRoot) {
if (Test-Path -LiteralPath $root) {
Remove-Item -LiteralPath $root -Recurse -Force
}
2026-07-10 03:34:49 +01:00
}
$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
# Release gate: cultlib compiles CultMath from source and ships beside org.gamecult.cultmath, which owns
# CultMath.dll and is resolved through the package.json dependency. Two gates bind the two:
# (1) The declared dependency version must equal the CultMath package's own version. Checked here,
# before anything is built.
# (2) Every CultMath.dll the publishes below produce must equal, byte for byte, the one that package
# tracks. Checked once they exist. The builds are deterministic for one toolchain and checkout, so a
# mismatch means the CultMath source changed since that package was released, its checkout's line
# endings differ from the CRLF that .gitattributes pins, or this SDK, compiler or host builds
# different bytes from the one that built the tracked DLL (no global.json pins the SDK).
$cultMathPackageRoot = Join-Path $repoRoot "packages\cultmath\unity\org.gamecult.cultmath"
$cultMathDeclared = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).dependencies.'org.gamecult.cultmath'
$cultMathAvailable = (Get-Content -LiteralPath (Join-Path $cultMathPackageRoot "package.json") -Raw | ConvertFrom-Json).version
if ([string]::IsNullOrWhiteSpace($cultMathDeclared)) {
throw "package.json must declare org.gamecult.cultmath, which supplies CultMath.dll."
}
foreach ($declared in @(@("org.gamecult.cultlib's org.gamecult.cultmath dependency", $cultMathDeclared), @("the CultMath package version", $cultMathAvailable))) {
if ($declared[1] -notmatch '^\d+\.\d+\.\d+$') {
throw "$($declared[0]) is '$($declared[1])'. The release gate compares exact release versions (major.minor.patch); prerelease and range versions are not supported."
}
}
if ([version]$cultMathDeclared -ne [version]$cultMathAvailable) {
throw "Release order: org.gamecult.cultlib declares org.gamecult.cultmath $cultMathDeclared but the CultMath package is at $cultMathAvailable; cultlib compiles against that package's source, so they must be equal. Release CultMath first, then declare its version."
}
# The tracked DLLs and pdbs are committed beside their source, so none may name a commit or a worktree:
# Source Link writes the commit SHA into the pdb, the informational version carries it too, and each DLL
# carries its pdb's content id. ContinuousIntegrationBuild maps the local source path to /_/.
# dotnet publish has no --no-incremental, so clear the configuration's intermediates instead: a stale obj
# directory must not decide what the byte check compares. Directory.Build.props puts every project's
# intermediates under obj\<project dir>, CultMath's at obj\packages\cultmath\src\CultMath, so clear the
# configuration directory beside every restored project's project.assets.json, not only those under obj\src.
@(Get-ChildItem -LiteralPath (Join-Path $repoRoot "obj") -Recurse -File -Filter "project.assets.json" -ErrorAction SilentlyContinue) |
ForEach-Object { Join-Path $_.DirectoryName $Configuration } |
Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Remove-Item -LiteralPath $_ -Recurse -Force }
$deterministicArguments = @("--disable-build-servers", "-p:UseSharedCompilation=false",
"-p:ContinuousIntegrationBuild=true", "-p:EnableSourceLink=false",
"-p:IncludeSourceRevisionInInformationalVersion=false", "-m:1")
foreach ($publish in $managedPublishes) {
$publishArguments = @("publish", (Join-Path $repoRoot $publish.Project), "-c", $Configuration) + $publish.Extra +
@("-o", $publish.Root, "-p:CultLibPackageVersion=$unityPackageVersion") + $deterministicArguments
if ($NoRestore) { $publishArguments += "--no-restore" }
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "$($publish.Label) publish failed with exit code $LASTEXITCODE"
}
}
# Gate (2): the CultMath bytes cultlib was built against are the bytes org.gamecult.cultmath ships.
$cultMathTrackedHash = (Get-FileHash -LiteralPath (Join-Path $cultMathPackageRoot "Runtime\Plugins\CultMath.dll") -Algorithm SHA256).Hash
$cultMathComparedRoots = @()
foreach ($publish in $managedPublishes) {
$builtCultMath = Join-Path $publish.Root "CultMath.dll"
if (-not (Test-Path -LiteralPath $builtCultMath)) { continue }
if ((Get-FileHash -LiteralPath $builtCultMath -Algorithm SHA256).Hash -ne $cultMathTrackedHash) {
throw "Release order: the CultMath.dll built into $($publish.Root) differs from the one org.gamecult.cultmath $cultMathAvailable tracks. Either CultMath source changed since that release, its checkout has other line endings, or this toolchain builds different bytes from the one that built the tracked DLL. Check first: git log cultmath-unity-v$cultMathAvailable.. -- packages/cultmath/src for a source change; git ls-files --eol packages/cultmath/src, where every file must read w/crlf (.gitattributes pins it; a clone checked out before that rule needs the files checked out again); then dotnet --version and the host against the build that produced the tracked DLL. A source change means release CultMath first, then declare its version; a toolchain difference means build with the toolchain that produced the tracked DLL."
}
$cultMathComparedRoots += $publish.Root
}
if ($cultMathComparedRoots.Count -eq 0) {
throw "No managed publish produced CultMath.dll, so the CultMath byte gate measured nothing."
}
Write-Host "CultMath byte gate: CultMath.dll in $($cultMathComparedRoots -join ', ') equals org.gamecult.cultmath $cultMathAvailable's tracked DLL."
& (Join-Path $PSScriptRoot "build-quic-native.ps1") `
-Configuration $Configuration `
-OutputDirectory $quicNativeRoot
2026-07-10 03:34:49 +01:00
New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null
Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot
Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot
Copy-Item -LiteralPath (Join-Path $templateRoot "CHANGELOG.md") -Destination $outputRoot
2026-07-10 03:34:49 +01:00
$expectedAssemblies = @(
"ConcurrentCollections.dll",
"GameCult.Caching.dll",
"GameCult.Caching.MessagePack.dll",
"GameCult.Logging.dll",
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
2026-07-10 03:34:49 +01:00
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll",
2026-07-10 03:34:49 +01:00
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",
"LiteNetLib.dll",
"MessagePack.Annotations.dll",
"MessagePack.dll",
"Microsoft.Bcl.AsyncInterfaces.dll",
"Microsoft.Bcl.TimeProvider.dll",
"Microsoft.NET.StringTools.dll",
"R3.dll",
"System.Collections.Immutable.dll",
"System.ComponentModel.Annotations.dll",
"System.IO.Pipelines.dll",
"System.Runtime.CompilerServices.Unsafe.dll",
"System.Text.Encodings.Web.dll",
"System.Text.Json.dll",
"System.Threading.Channels.dll"
)
# Assemblies the package references but never ships: org.gamecult.cultmath owns CultMath.dll, and the
# package.json dependency delivers it. A second copy in Runtime\Plugins would be a duplicate assembly.
$externalAssemblies = @("CultMath.dll")
$asmdef = Get-Content -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") -Raw | ConvertFrom-Json
$referencedAssemblies = @($asmdef.precompiledReferences | Sort-Object)
$declaredAssemblies = @($expectedAssemblies + $externalAssemblies | Sort-Object)
if (($referencedAssemblies -join "|") -ne ($declaredAssemblies -join "|")) {
throw "GameCult.CultLib.asmdef precompiledReferences differ from the shipped plus external assembly lists."
}
2026-07-10 03:34:49 +01:00
$publishedByName = @{}
foreach ($publish in $managedPublishes) {
foreach ($assembly in Get-ChildItem -LiteralPath $publish.Root -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
}
2026-07-10 03:34:49 +01:00
foreach ($assemblyName in $expectedAssemblies) {
if (-not $publishedByName.ContainsKey($assemblyName)) {
throw "CultLib Unity package is missing expected assembly: $assemblyName"
}
}
# docs/semver-policy.md: refuse a release whose version does not tell the truth. The check measures
# the public API of every shipped assembly against the DLLs the previous cultlib-unity tag
# tracked (read from git), so it runs here, once the assemblies under judgement exist. Every
# tracked assembly the build does not hand over counts as removed, so all of them are handed over.
# The package's tag prefix, changelog and assemblies directory are declared in
# scripts/release-packages.mjs; this call passes only the version and the built assemblies.
$measuredArguments = @()
foreach ($assemblyName in $expectedAssemblies) {
$measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName)
}
foreach ($publish in $managedPublishes) {
$measuredArguments += @("--api-refs", $publish.Root)
}
# The suite pins the checker's rules and the declared tag prefixes, so it runs first.
& node --test (Join-Path $repoRoot "scripts\check-changelog-semver.test.mjs")
if ($LASTEXITCODE -ne 0) {
throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing."
}
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultlib" `
--version $unityPackageVersion `
--cwd $repoRoot `
@measuredArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)."
}
2026-07-10 03:34:49 +01:00
Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") `
-Destination (Join-Path $outputRoot "Runtime")
foreach ($assemblyName in $expectedAssemblies) {
$assembly = $publishedByName[$assemblyName]
Copy-Item -LiteralPath $assembly.FullName -Destination $pluginRoot
$pdb = [System.IO.Path]::ChangeExtension($assembly.FullName, ".pdb")
if (Test-Path -LiteralPath $pdb) {
Copy-Item -LiteralPath $pdb -Destination $pluginRoot
}
}
$nativePluginRoot = Join-Path $pluginRoot "x86_64"
New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "msquic.dll") -Destination $nativePluginRoot
New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party Notices") | Out-Null
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") `
-Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt")
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "OPENSSL-NOTICE.txt") `
-Destination (Join-Path $outputRoot "Third Party Notices\OPENSSL-NOTICE.txt")
2026-07-10 03:34:49 +01:00
$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll"
$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString()
if ($meshPackageVersion -ne "$unityPackageVersion.0") {
throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'."
}
$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath))
foreach ($requiredType in @(
"GameCult.Mesh.CultMeshAuthorityTrustPolicy",
"GameCult.Mesh.CultMeshRouteCertificate",
"GameCult.Mesh.CultMeshHttpsContentTransportConnector")) {
if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) {
throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'."
}
}
$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString(
[IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll")))
if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) {
throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client."
}
if ($UpdateTemplate) {
$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins"
foreach ($assemblyName in $expectedAssemblies) {
Copy-Item -LiteralPath (Join-Path $pluginRoot $assemblyName) -Destination $templatePluginRoot -Force
$pdbName = [System.IO.Path]::ChangeExtension($assemblyName, ".pdb")
$pdb = Join-Path $pluginRoot $pdbName
if (Test-Path -LiteralPath $pdb) {
Copy-Item -LiteralPath $pdb -Destination $templatePluginRoot -Force
}
}
$templateNativePluginRoot = Join-Path $templatePluginRoot "x86_64"
New-Item -ItemType Directory -Force -Path $templateNativePluginRoot | Out-Null
Copy-Item -LiteralPath (Join-Path $nativePluginRoot "gamecult_mesh_quic_native.dll") `
-Destination $templateNativePluginRoot -Force
Copy-Item -LiteralPath (Join-Path $nativePluginRoot "msquic.dll") `
-Destination $templateNativePluginRoot -Force
$templateNoticesRoot = Join-Path $templateRoot "Third Party Notices"
New-Item -ItemType Directory -Force -Path $templateNoticesRoot | Out-Null
Copy-Item -LiteralPath (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") `
-Destination $templateNoticesRoot -Force
Copy-Item -LiteralPath (Join-Path $outputRoot "Third Party Notices\OPENSSL-NOTICE.txt") `
-Destination $templateNoticesRoot -Force
Write-Host "Updated tracked Unity package assemblies: $templatePluginRoot"
}
2026-07-10 03:34:49 +01:00
$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json
Write-Host "CultLib Unity package: $outputRoot"
Write-Host "Package: $($manifest.name)@$($manifest.version)"
Write-Host "Managed assemblies: $($expectedAssemblies.Count)"
Pin the Windows MsQuic to the OpenSSL build, and both of its digests Q9 ruled A. The Schannel MsQuic refuses QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12 with QUIC_STATUS_NOT_SUPPORTED, and PKCS12 is the only credential type a provider has on both platforms, so a Windows provider could not open a listener against the runtime this repo was shipping. The same bridge binary passes all twenty smoke checks and the v1 gate against the OpenSSL build, which is what Soul verified and what this now downloads. Two digests, not one. The zip is pinned by FEE9A664 and 19,179,817 bytes; the msquic.dll inside it by C17C6581 and 4,181,856 bytes. The earlier report and the cut map's first record put the zip's digest on the DLL, which is a pin that checks the archive and then ships a file nobody looked at. Sizes ride along with both, because a digest that fails tells you less than a digest and a length that fail together. The CRT is linked statically. A Node or Unity host without the Visual C++ redistributable cannot load a DLL that imports vcruntime140.dll, and Windows reports that as "the specified module could not be found" while naming the module that was found. Confirmed on the rebuild: msquic.dll, CRYPT32, ntdll, KERNEL32, and no CRT import at all. The DLL goes from 83,456 to 302,080 bytes, which is the price of the host not needing anything installed. The Unity plugin's committed msquic.dll is still the Schannel one and this does not touch it; the next build-unity-package.ps1 run is what replaces it, and that is not in this batch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:04:27 +02:00
Write-Host "Native realtime: MsQuic OpenSSL 2.5.9 (Windows x64)"