diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f56abc80b..b3336e71e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,25 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Validate Windows packaging PowerShell syntax + shell: powershell + run: | + $scripts = @( + '.\ArIED61850Tester\scripts\build-ardirec-bridge.ps1', + '.\ArIED61850Tester\scripts\publish-windows-portable.ps1', + '.\ArIED61850Tester\scripts\build-windows-installer.ps1' + ) + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = ($errors | ForEach-Object Message) -join '; ' + throw "PowerShell syntax validation failed for ${script}: $messages" + } + } + - name: Set up Python for read-only maintenance inventory uses: actions/setup-python@v6 with: @@ -415,5 +434,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: ARSAS-win-x64-portable-single-exe - path: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json if-no-files-found: error diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 4901ff0bd..4227a8b8d 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -383,6 +383,7 @@ jobs: name: ARSAS-smart-discovery-pr134-win-x64 path: | ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 diff --git a/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md new file mode 100644 index 000000000..8a6e6c45e --- /dev/null +++ b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md @@ -0,0 +1,48 @@ +# CI-P3A — Reproducible Windows portable build identity + +Issue #447. + +## Purpose + +P2F proved that matching ARSAS and ARIEC61850 revisions could still produce +different portable single-file executables in independent workflows. Therefore +binary artifact reuse remains prohibited until reproducibility is proven. + +## Root-cause hypothesis and controlled change + +The Windows package embeds a pinned ArdIrec native bridge. That bridge is built +independently with MSVC/CMake in each workflow. A PE linker timestamp or other +non-reproducible native metadata can change the embedded bridge and, because the +single-file payload is compressed, propagate into a large executable diff. + +P3A does not treat that hypothesis as accepted evidence. It makes the native +shared-library linker request reproducible output with /Brepro and makes the +.NET publish determinism intent explicit with Deterministic=true and +ContinuousIntegrationBuild=true. Existing ArdIrec native regression tests remain. + +## Build identity + +Each single-file publish writes a JSON identity next to the executable containing: + +- ARSAS source commit when the source is a Git checkout; +- ARIEC61850 engine commit when the project is a Git checkout; +- pinned ArdIrec lock commit; +- ArdIrec bridge SHA-256 and byte size; +- portable executable SHA-256 and byte size; +- version/runtime and deterministic-build flags. + +Both Build ARSAS and Smart Discovery Field Capture upload that identity. + +## Acceptance boundary + +P3A itself does not authorize installer/release artifact reuse. After CI, the two +independent builders must report identical source, engine and ArdIrec inputs and +identical bridge plus portable SHA-256. Only that measured result can unlock P3B. + +Installer/release/physical authority and runtime behavior are unchanged. + +## Efficiency + +No new heavy workflow is introduced. Existing canonical and Field Capture lanes +serve as the independent builders, so reproducibility evidence is obtained from +work already required by the repository. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index c86d1bae6..73812cbc1 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2F: canonical regression reused by independent Field Capture packaging", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2E consolidate exact-SHA regression evidence; P2F keeps Field Capture source/wire checks and independent publish/smoke, but reuses canonical application regression because source and integration engine are identical. Binary artifact reuse remains forbidden because observed canonical and Field Capture EXEs were not byte-identical.", + "baselineName": "CI-P3A: reproducible portable build identity", + "policy": "P0-P2 reduced duplicate regression work. P3A does not add a heavy workflow or change static CI counts; it establishes deterministic Windows packaging inputs and hash-bound build identity so later artifact promotion can reuse only proven identical bits.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -291,5 +291,23 @@ "canonicalBuildRemainsRegressionAuthorityOnPullRequest": true, "manualReusableRegressionRetained": true, "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." + }, + "ciP3ReproduciblePortableBuild": { + "issueNumber": 447, + "lane": "canonical-and-field-capture-windows-packaging", + "approach": "Request reproducible MSVC bridge linking, explicit deterministic .NET publish, and emit source/engine/ArdIrec/bridge/portable hash identity from both existing independent builders. No binary promotion until cross-run parity is proven.", + "staticLimitsChanged": false, + "nativeLinkerFlag": "/Brepro", + "managedDeterminism": [ + "Deterministic=true", + "ContinuousIntegrationBuild=true" + ], + "crossRunBuilders": [ + "Build ARSAS", + "Smart Discovery Field Capture Build" + ], + "binaryPromotionAllowed": false, + "acceptance": "Matching source, ARIEC61850 and ArdIrec inputs must yield identical bridge and portable SHA-256 across independent workflow runs.", + "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition." } } diff --git a/scripts/build-ardirec-bridge.ps1 b/scripts/build-ardirec-bridge.ps1 index a5f502735..7fcf4d287 100644 --- a/scripts/build-ardirec-bridge.ps1 +++ b/scripts/build-ardirec-bridge.ps1 @@ -84,7 +84,8 @@ Write-Host "==> Configuring pinned ArdIrec native bridge (desktop/Qt disabled)" -DARDIREC_BUILD_DESKTOP=OFF ` -DARDIREC_BUILD_BRIDGE=ON ` -DARDIREC_BUILD_TESTS=ON ` - -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded ` + -DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro if ($LASTEXITCODE -ne 0) { throw "ArdIrec bridge CMake configure failed with exit code $LASTEXITCODE." } diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index aaa0fefc6..e3aa95190 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -116,6 +116,8 @@ $publishArguments = @( "-p:UseAppHost=true", "-p:DebugType=None", "-p:DebugSymbols=false", + "-p:Deterministic=true", + "-p:ContinuousIntegrationBuild=true", "-p:Version=$normalizedVersion", "-p:AssemblyVersion=$numericVersion", "-p:FileVersion=$numericVersion", @@ -161,7 +163,54 @@ if ($SingleFile) { throw "Versioned portable single EXE was not produced: $singleExePath" } + function Get-GitIdentity([string]$path) { + try { + $repoRoot = (& git -C $path rev-parse --show-toplevel 2>$null).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { return $null } + $head = (& git -C $repoRoot rev-parse HEAD 2>$null).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40}$') { return $null } + return $head + } + catch { + return $null + } + } + + $engineProjectDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($EngineProject)) + $sourceCommit = Get-GitIdentity $root + $engineCommit = Get-GitIdentity $engineProjectDirectory + $ardirecLockPath = Join-Path $root "engines\ARDIREC.lock.json" + $ardirecLockCommit = $null + if (Test-Path $ardirecLockPath -PathType Leaf) { + $ardirecLock = Get-Content $ardirecLockPath -Raw | ConvertFrom-Json + if ($ardirecLock.commit -match '^[0-9a-f]{40}$') { + $ardirecLockCommit = [string]$ardirecLock.commit + } + } + + $bridgeHash = (Get-FileHash $ArdIrecBridgePath -Algorithm SHA256).Hash.ToLowerInvariant() + $portableHash = (Get-FileHash $singleExePath -Algorithm SHA256).Hash.ToLowerInvariant() + $identityPath = Join-Path $outputRoot "ARSAS-$normalizedVersion-$Runtime-portable-build-identity.json" + [ordered]@{ + schemaVersion = 1 + kind = "arsas-portable-build-identity" + version = $normalizedVersion + runtime = $Runtime + sourceCommit = $sourceCommit + engineCommit = $engineCommit + ardIrecLockCommit = $ardirecLockCommit + ardIrecBridgeSha256 = $bridgeHash + ardIrecBridgeSizeBytes = (Get-Item $ArdIrecBridgePath).Length + portableSha256 = $portableHash + portableSizeBytes = (Get-Item $singleExePath).Length + deterministicManagedBuild = $true + reproducibleNativeLinkRequested = $true + } | ConvertTo-Json -Depth 4 | Set-Content $identityPath -Encoding utf8 + Write-Host "==> Real portable single EXE with embedded ArdIrec bridge: $singleExePath" + Write-Host "==> Portable build identity: $identityPath" + Write-Host "==> Portable SHA256: $portableHash" + Write-Host "==> ArdIrec bridge SHA256: $bridgeHash" Write-Output $singleExePath exit 0 } diff --git a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs new file mode 100644 index 000000000..ec154884e --- /dev/null +++ b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs @@ -0,0 +1,75 @@ +namespace ARSAS.Tests; + +public sealed class WindowsPackagingReproducibilityContractTests +{ + [Fact] + public void NativeBridge_RequestsReproducibleMsvcLinkOutput() + { + var bridge = File.ReadAllText( + FindRepositoryFile("scripts/build-ardirec-bridge.ps1")); + + Assert.Contains( + "-DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "ctest --test-dir", + bridge, + StringComparison.Ordinal); + } + + [Fact] + public void PortablePublish_IsDeterministicAndEmitsHashBoundBuildIdentity() + { + var publish = File.ReadAllText( + FindRepositoryFile("scripts/publish-windows-portable.ps1")); + + Assert.Contains("-p:Deterministic=true", publish, StringComparison.Ordinal); + Assert.Contains("-p:ContinuousIntegrationBuild=true", publish, StringComparison.Ordinal); + Assert.Contains("arsas-portable-build-identity", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecBridgeSha256", publish, StringComparison.Ordinal); + Assert.Contains("portableSha256", publish, StringComparison.Ordinal); + Assert.Contains("sourceCommit", publish, StringComparison.Ordinal); + Assert.Contains("engineCommit", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecLockCommit", publish, StringComparison.Ordinal); + Assert.Contains("reproducibleNativeLinkRequested = $true", publish, StringComparison.Ordinal); + } + + [Fact] + public void CanonicalAndFieldCaptureArtifacts_BothCarryBuildIdentity() + { + var canonical = File.ReadAllText( + FindRepositoryFile(".github/workflows/build.yml")); + var capture = File.ReadAllText( + FindRepositoryFile(".github/workflows/smart-discovery-capture-build.yml")); + + const string identity = + "ArIED61850Tester\\dist\\ARSAS-*-win-x64-portable-build-identity.json"; + + Assert.Contains(identity, canonical, StringComparison.Ordinal); + Assert.Contains(identity, capture, StringComparison.Ordinal); + Assert.Contains( + "Packaging authority: independent Field Capture publish/smoke", + capture, + StringComparison.Ordinal); + } + + private static string FindRepositoryFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Repository file not found: {relativePath}"); + } +}