Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/workflows/smart-discovery-capture-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions evidence/ci-workflow-budget.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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."
}
}
3 changes: 2 additions & 1 deletion scripts/build-ardirec-bridge.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
49 changes: 49 additions & 0 deletions scripts/publish-windows-portable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report reproducible linking only when it occurred

When callers supply -ArdIrecBridgePath, or when build-ardirec-bridge.ps1 reuses its existing destination DLL, this publish invocation never links the bridge with /Brepro; nevertheless, the emitted identity unconditionally attests that reproducible native linking was requested. This can make an arbitrary or stale bridge appear to carry reproducible-build provenance in evidence later used for cross-run acceptance. Derive this field from the actual bridge-build path, or verify/rebuild the bridge before setting it to true.

AGENTS.md reference: AGENTS.md:L11-L17

Useful? React with 👍 / 👎.

} | 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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}");
}
}
Loading