diff --git a/.github/workflows/_ci-p1-windows-build-test.yml b/.github/workflows/_ci-p1-windows-build-test.yml index 92eaa2e61..73d326fe0 100644 --- a/.github/workflows/_ci-p1-windows-build-test.yml +++ b/.github/workflows/_ci-p1-windows-build-test.yml @@ -11,6 +11,18 @@ on: description: Prevent this primitive from being mistaken for release/promotion authority type: boolean default: true + fast_dev: + description: Opt-in bounded test scope; not a FAT or release gate + type: boolean + default: false + focus: + description: FAST DEV regression selection + type: string + default: all + engine_trial_sha: + description: Explicit experimental ARIEC commit for isolated FAST DEV only + type: string + default: '' permissions: contents: read @@ -61,7 +73,7 @@ jobs: uses: actions/checkout@v7 with: repository: ${{ steps.engine.outputs.repository }} - ref: ${{ steps.engine.outputs.commit }} + ref: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} path: ARIEC61850 fetch-depth: 1 persist-credentials: false @@ -69,14 +81,28 @@ jobs: - name: Verify exact engine and immutable history provenance shell: pwsh env: - EXPECTED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + EXPECTED_ENGINE_SHA: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} + PINNED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + FAST_DEV: ${{ inputs.fast_dev }} run: | $ErrorActionPreference = 'Stop' + if ($env:TRIAL_ENGINE_SHA -and $env:FAST_DEV -ne 'true') { + throw 'An engine trial is forbidden outside explicit FAST DEV.' + } + if ($env:TRIAL_ENGINE_SHA -and $env:TRIAL_ENGINE_SHA -cnotmatch '^[0-9a-f]{40}$') { + throw 'ENGINE_TRIAL_SHA must be an exact 40-character Git SHA.' + } $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() if ($actual -cne $env:EXPECTED_ENGINE_SHA) { - throw "ARIEC61850 pinned checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + throw "ARIEC61850 exact checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + } + if ($env:TRIAL_ENGINE_SHA) { + if ($actual -ceq $env:PINNED_ENGINE_SHA) { throw 'Trial engine unexpectedly matches production pin.' } + Write-Host "NON-PROMOTABLE ENGINE TRIAL: $actual; unchanged production pin: $env:PINNED_ENGINE_SHA" + } else { + Write-Host "Reproducible compilation: ARSAS + production pinned ARIEC61850 $actual." } - Write-Host "Reproducible compilation authority: ARSAS + pinned ARIEC61850." - name: Setup .NET 8 uses: actions/setup-dotnet@v6 @@ -91,20 +117,224 @@ jobs: restore-keys: | ci-p1-nuget-win-x64- + - name: Materialize temporary FAST DEV engine validation lock + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' }} + shell: pwsh + env: + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + APPROVED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $lock = Get-Content -LiteralPath '.\ARSAS\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($lock.commit -cne $env:APPROVED_ENGINE_SHA -or + $env:TRIAL_ENGINE_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $actual -cne $env:TRIAL_ENGINE_SHA -or + $actual -ceq $lock.commit) { + throw 'Trial engine identity or approved production baseline disagrees.' + } + $lock.commit = $actual + $lock | Add-Member -NotePropertyName nonPromotableTrial -NotePropertyValue $true -Force + $lock | Add-Member -NotePropertyName approvedBaselineCommit -NotePropertyValue $env:APPROVED_ENGINE_SHA -Force + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + $lock | ConvertTo-Json -Depth 64 | Set-Content -LiteralPath $ephemeral -Encoding utf8 + Write-Host "Validated CI-only engine $actual against unchanged approved lock." + - name: Restore solution once run: dotnet restore .\ARSAS\ArIED61850Tester.sln - name: Build solution once - run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + shell: pwsh + env: + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + run: | + $ErrorActionPreference = 'Stop' + $buildArgs = @('.\ARSAS\ArIED61850Tester.sln', '-c', 'Release', '--no-restore') + if ($env:TRIAL_ENGINE_SHA) { + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path -LiteralPath $ephemeral)) { throw 'Trial-only validator lock missing.' } + $buildArgs += "-p:ArIec61850LockPath=$ephemeral" + } + & dotnet build @buildArgs + if ($LASTEXITCODE -ne 0) { throw 'CI-P1 Windows compilation failed.' } + + - name: Run ARSAS regression (full or focused FAST DEV) + shell: pwsh + env: + FAST_DEV: ${{ inputs.fast_dev }} + FOCUS: ${{ inputs.focus }} + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + run: | + $ErrorActionPreference = 'Stop' + $arguments = @( + 'test', '.\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj', + '-c', 'Release', '--no-build', '--no-restore', + '--logger', 'trx;LogFileName=ci-p1-shadow.trx', + '--results-directory', '.\ARSAS\TestResults' + ) + if ($env:FAST_DEV -eq 'true') { + $filter = switch ($env:FOCUS) { + 'quality-browser' { 'FullyQualifiedName~QualityInvalidAttentionTests|FullyQualifiedName~IedBrowserPresentationTests|FullyQualifiedName~M2TypedQualityEvidenceTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests|FullyQualifiedName~P0SiebcuReadAllAndLayoutRegressionTests' } + 'smv' { 'FullyQualifiedName~SmvArstack|FullyQualifiedName~SmvLiveCaptureTests|FullyQualifiedName~SmvObservedTimebaseTests|FullyQualifiedName~SmvDigSubPortTests' } + 'command-read' { 'FullyQualifiedName~ModelDrivenAutoCommandDockTests|FullyQualifiedName~P109UStableCommandProjectionTests|FullyQualifiedName~P109WCommandDockLifecycleArchitectureTests|FullyQualifiedName~P0CswiCrossDataSetCommandDiscoveryTests|FullyQualifiedName~P0GrxCswiSelectionVisibilityTests|FullyQualifiedName~P0GrxSclLiveCswiParityTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests|FullyQualifiedName~P109KGenericControlSafetyTests|FullyQualifiedName~ZivBscStepControlRegressionTests|FullyQualifiedName~G1ControlCorrectnessRegressionTests' } + 'all' { 'FullyQualifiedName~QualityInvalidAttentionTests|FullyQualifiedName~SmvArstack|FullyQualifiedName~P0CswiCrossDataSetCommandDiscoveryTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests' } + default { throw 'Unknown FAST DEV scope; refusing to silently skip tests.' } + } + $arguments += @('--filter', $filter) + Write-Host "NON-FAT scoped regression only ($env:FOCUS). No release/physical authority." + } else { + Write-Host "FULL ARSAS regression; no selected-test filter." + } + if ($env:TRIAL_ENGINE_SHA) { + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path -LiteralPath $ephemeral)) { throw 'Trial-only validator lock missing before test.' } + $arguments += "-p:ArIec61850LockPath=$ephemeral" + } + # One .NET test invocation for both modes; CI sprawl budget unchanged. + & dotnet @arguments + if ($LASTEXITCODE -ne 0) { throw 'Selected ARSAS regression failed.' } + $trxPath = '.\ARSAS\TestResults\ci-p1-shadow.trx' + if (-not (Test-Path -LiteralPath $trxPath)) { + throw 'No TRX proof for selected regression; refusing empty pass.' + } + [xml]$trx = Get-Content -LiteralPath $trxPath -Raw + $counts = $trx.TestRun.ResultSummary.Counters + if ($null -eq $counts -or [int]$counts.executed -lt 1 -or [int]$counts.failed -gt 0) { + throw "No actual passing tests or failures in FAST DEV/full TRX." + } + Write-Host "Exact-source regression results: executed=$($counts.executed) passed=$($counts.passed) failed=$($counts.failed)." - - name: Run full ARSAS regression suite once - run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=ci-p1-shadow.trx" --results-directory .\ARSAS\TestResults + - name: Build locked ArdIrec bridge and native regression for SG7 BCU trial + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $bridge = [string](.\ARSAS\scripts\build-ardirec-bridge.ps1 ` + -OutputDirectory (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-bridge') ` + -CTestJunitPath (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') | + Select-Object -Last 1) + $bridge = $bridge.Trim() + if (-not (Test-Path -LiteralPath $bridge -PathType Leaf) -or + -not (Test-Path (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') -PathType Leaf)) { + throw 'Native bridge build / CTest evidence missing.' + } + "SG7_NATIVE_BRIDGE=$bridge" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Publish actual self-contained SG7 BCU Ed2 trial Windows EXE + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + env: + ARSAS_NONPROMOTION_ENGINE_TRIAL: 'true' + run: | + $ErrorActionPreference = 'Stop' + $temporary = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path $temporary -PathType Leaf) -or + -not (Test-Path $env:SG7_NATIVE_BRIDGE -PathType Leaf)) { + throw 'Engine/ArdIrec exact-SHA trial packaging prerequisites missing.' + } + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version '1.6.40-sg7bcued2trial' ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecBridgePath $env:SG7_NATIVE_BRIDGE ` + -EngineValidationLockPath $temporary + if ($LASTEXITCODE -ne 0) { throw 'Single EXE trial publish failed.' } + + - name: Execute portable EXE smoke and seal physical FAT trial identity + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + env: + SOURCE_SHA: ${{ inputs.source_sha }} + EXPERIMENTAL_ENGINE: ${{ inputs.engine_trial_sha }} + BASELINE_ENGINE: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $root = '.\ARSAS\dist' + $source = Join-Path $root 'ARSAS-1.6.40-sg7bcued2trial-win-x64-portable.exe' + $identityPath = Join-Path $root 'ARSAS-1.6.40-sg7bcued2trial-win-x64-portable-build-identity.json' + if (-not (Test-Path $source -PathType Leaf) -or -not (Test-Path $identityPath -PathType Leaf)) { + throw 'Single file EXE / portable identity missing.' + } + $ident = Get-Content $identityPath -Raw | ConvertFrom-Json + $exeHash = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() + $bridgeHash = (Get-FileHash $env:SG7_NATIVE_BRIDGE -Algorithm SHA256).Hash.ToLowerInvariant() + $sourceSha = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + $engineSha = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + $lock = Get-Content '.\ARSAS\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + if ($ident.sourceCommit -cne $env:SOURCE_SHA -or + $ident.engineCommit -cne $env:EXPERIMENTAL_ENGINE -or + $ident.ardIrecBridgeSha256 -cne $bridgeHash -or + $ident.portableSha256 -cne $exeHash -or + $sourceSha -cne $env:SOURCE_SHA -or + $engineSha -cne $env:EXPERIMENTAL_ENGINE -or + $lock.commit -cne $env:BASELINE_ENGINE -or + $engineSha -ceq $env:BASELINE_ENGINE) { + throw 'Exact SHA provenance, binary hash, or unchanged production lock failed.' + } + $exePath = (Resolve-Path $source).Path + $proc = Start-Process -FilePath $exePath -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $proc.WaitForExit(90000)) { + $proc.Kill() + throw 'Portable EXE smoke timeout.' + } + if ($proc.ExitCode -ne 0) { throw "Portable EXE smoke failed: $($proc.ExitCode)" } + $out = '.\ARSAS\TestResults\sg7-bcu-fat-trial' + New-Item -ItemType Directory -Path $out -Force | Out-Null + $name = 'ARSAS-SG7_BCU-ED2-FAT-TRIAL-' + $engineSha.Substring(0,8) + '-win-x64.exe' + $exe = Join-Path $out $name + Copy-Item $source $exe -Force + if ((Get-FileHash $exe -Algorithm SHA256).Hash.ToLowerInvariant() -cne $exeHash) { + throw 'Staged trial EXE differs from smoke-tested EXE.' + } + Copy-Item $identityPath (Join-Path $out 'portable-build-identity.json') -Force + Copy-Item (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') (Join-Path $out 'native-ardirec-ctest.xml') -Force + [ordered]@{ + schemaVersion = 1 + kind = 'arsas-nonrelease-supervised-iec61850-ed2-fat-trial' + sourceSha = $sourceSha + engineTrialSha = $engineSha + sourceApprovedEngineSha = $lock.commit + ardIrecSourceSha = $ident.ardIrecLockCommit + nativeBridgeSha256 = $bridgeHash + exeFileName = $name + exeSha256 = $exeHash + exeSizeBytes = (Get-Item $exe).Length + smokeTestExitCode = $proc.ExitCode + regressionTrx = 'ci-p1-shadow.trx' + externalRelayTestPerformed = $false + productionReleaseAuthority = $false + } | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $out 'FAT-TRIAL-SHA256.json') -Encoding utf8 + @' + ARSAS SG7_BCU IEC 61850 EDITION 2 | SUPERVISED FAT TRIAL + ------------------------------------------------------- + THIS IS NOT AN OFFICIAL RELEASE; NO PHYSICAL RELAY TEST HAS BEEN PERFORMED. + Extract the EXE. Check its SHA256 against FAT-TRIAL-SHA256.json + using PowerShell: Get-FileHash -Algorithm SHA256 .\ARSAS-SG7_BCU-*.exe + Single-file self-contained Windows x64, pinned native bridge included. + Stage 1: Connect / discover / monitor / reporting READ ONLY. + Stage 2: Inspect verified Command Dock candidates, including renamed + GE Bkr0XCBR2.Pos, disconnector and TapChg, and confirm StatusOnly blocked. + Stage 3: Only under test engineer/operator approval, physically isolated + safe bench and documented switching permission, test SBOw / Operate / + CommandTermination; preserve interlock, negative response, LastApplError, + actual status q/t, reporting BufferOverflow/Invalid evidence. + No automatic Operate retry when acceptance/feedback is uncertain. + An automation PASS does not prove physical command success. + The repository production engine lock remains the qualified #184. + '@ | Set-Content (Join-Path $out 'READ-ME-FIRST.txt') -Encoding utf8 + Write-Host "EXE SMOKE SUCCESS: $name SHA256 $exeHash" + Write-Host 'SUPERVISED FAT candidate only. No release or physical command authority.' - name: Write non-promotion build authority manifest if: always() shell: pwsh env: - ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_SHA: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} + BASELINE_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_TRIAL_SHA: ${{ inputs.engine_trial_sha }} ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} HISTORICAL_ENGINE_SHA: ${{ steps.engine.outputs.merged-main-commit }} PHYSICAL_TESTED_ENGINE_SHA: ${{ steps.engine.outputs.physical-tested-commit }} @@ -118,7 +348,14 @@ jobs: Select-Object -ExpandProperty Name) $manifest = [ordered]@{ schemaVersion = 1 - kind = 'ci-p1-nonpromotion-shadow' + kind = if ('${{ inputs.fast_dev }}' -eq 'true') { 'ci-p1-fast-dev-nonfat' } else { 'ci-p1-nonpromotion-shadow' } + fastDev = '${{ inputs.fast_dev }}' + focus = '${{ inputs.focus }}' + engineTrialSha = $env:ENGINE_TRIAL_SHA + approvedPinnedEngineSha = $env:BASELINE_ENGINE_SHA + engineIsExperimental = -not [string]::IsNullOrWhiteSpace($env:ENGINE_TRIAL_SHA) + validatorLockKind = if ($env:ENGINE_TRIAL_SHA) { 'ephemeral-nonpromotion' } else { 'source-approved-lock' } + fullRegressionQualified = ('${{ inputs.fast_dev }}' -ne 'true' -and [string]::IsNullOrWhiteSpace($env:ENGINE_TRIAL_SHA)) exactArsasSha = $sourceSha exactEngineSha = $engineActual expectedEngineSha = $env:ENGINE_SHA @@ -136,13 +373,39 @@ jobs: throw 'CI-P1 shadow SHA/TRX proof is incomplete; fail closed.' } - - name: Upload inspectable shadow TRX and SHA manifest + # Single upload keeps CI-sprawl budget unchanged. FAST = raw executable; + # ordinary shadow = TRX + manifest diagnostics, unchanged gates. + - name: Choose FAST EXE or diagnostic artifact + id: artifact-mode + if: always() + shell: pwsh + env: + FAST_DEV: ${{ inputs.fast_dev }} + TRIAL_SHA: ${{ inputs.engine_trial_sha }} + FOCUS: ${{ inputs.focus }} + run: | + $trial = ($env:FAST_DEV -eq 'true' -and + $env:TRIAL_SHA -match '^[0-9a-f]{40}$' -and + $env:FOCUS -eq 'command-read') + if ($trial) { + "artifact_name=ARSAS-SG7-BCU-FAT-TRIAL-$env:GITHUB_RUN_ID.exe" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_path=ARSAS/TestResults/sg7-bcu-fat-trial/ARSAS-SG7_BCU-ED2-FAT-TRIAL-*-win-x64.exe" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_archive=false" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + "artifact_name=ci-p1-shadow-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_path<> "$GITHUB_OUTPUT" + import json + import re + + with open("engines/ARIEC61850.lock.json", encoding="utf-8") as handle: + production = json.load(handle) + with open("engines/ARIEC61850.trial.json", encoding="utf-8") as handle: + trial = json.load(handle) + sha = str(trial.get("commit", "")) + if (not re.fullmatch(r"[0-9a-f]{40}", sha) + or trial.get("runMode") != "fast-dev-nonpromotion" + or trial.get("scope") != "command-read" + or sha == production.get("commit")): + raise SystemExit("Invalid or non-isolated engine trial manifest") + print("sha=" + sha) + PY + fi + windows-shadow: name: Compare reusable build/test with legacy authority needs: classify @@ -82,6 +121,13 @@ jobs: }} uses: ./.github/workflows/_ci-p1-windows-build-test.yml with: - source_sha: ${{ github.sha }} + # PR validation must package the immutable branch HEAD, not GitHub's + # synthetic refs/pull//merge SHA. The synthetic merge remains covered + # independently by canonical PR CI; FAST DEV evidence must match the + # exact operator-downloaded source revision. + source_sha: ${{ github.event.pull_request.head.sha || github.sha }} shadow_mode: true + fast_dev: ${{ (github.event_name == 'workflow_dispatch' && inputs.fast_dev == true) || (github.event_name == 'pull_request' && needs.classify.outputs.engine_trial_sha != '') }} + focus: ${{ github.event_name == 'workflow_dispatch' && inputs.fast_dev == true && inputs.focus || needs.classify.outputs.engine_trial_sha != '' && 'command-read' || 'all' }} + engine_trial_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.fast_dev == true && inputs.engine_trial_sha || needs.classify.outputs.engine_trial_sha || '' }} secrets: {} diff --git a/.github/workflows/fat-direct-exe.yml b/.github/workflows/fat-direct-exe.yml deleted file mode 100644 index 6b31236b3..000000000 --- a/.github/workflows/fat-direct-exe.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Publish direct EXE FAT preview (manual only) - -# CI artifact downloads are always ZIP. PRs run ONLY read-only tests; -# publishing a public, non-production GitHub prerelease is manual-only. -on: - pull_request: - paths: - - '.github/workflows/fat-direct-exe.yml' - - 'scripts/verify-fat-direct-exe.py' - - 'scripts/test-fat-direct-exe.py' - workflow_dispatch: - inputs: - tested_sha: - description: CI-qualified exact 40-character source SHA - type: string - required: true - build_run_id: - description: Successful exact-SHA Build ARSAS workflow run ID - type: string - required: true - installer_run_id: - description: Optional successful exact-SHA installer run ID; 0 skips - type: string - required: true - default: '0' - public_preview_ack: - description: Type PUBLISH PUBLIC NONPRODUCTION FAT EXE to authorize public FAT-only assets - type: string - required: true - -permissions: - contents: read - actions: read - -concurrency: - group: fat-direct-exe-preview - cancel-in-progress: false - -jobs: - verifier-tests: - name: Reject stale, failed and disguised-fat-build artifacts - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout for safe offline verifier tests - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Run positive and negative direct EXE publication guard tests - run: python3 scripts/test-fat-direct-exe.py - - preview: - name: Verify tested PE bytes before manual immutable FAT prerelease - permissions: - contents: write - actions: read - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: fat-preview-publication - env: - GH_TOKEN: ${{ github.token }} - TESTED_SHA: ${{ inputs.tested_sha }} - BUILD_RUN_ID: ${{ inputs.build_run_id }} - INSTALLER_RUN_ID: ${{ inputs.installer_run_id }} - PUBLIC_ACK: ${{ inputs.public_preview_ack }} - steps: - - name: Checkout approved workflow and verifier, not untrusted candidate scripts - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - persist-credentials: false - fetch-depth: 1 - - - name: Verify explicit consent and exact HEAD full CI - shell: bash - run: | - set -euo pipefail - python3 scripts/test-fat-direct-exe.py - [[ "$PUBLIC_ACK" == "PUBLISH PUBLIC NONPRODUCTION FAT EXE" ]] || { - echo "An explicit public preview acknowledgement is required." >&2; exit 1; - } - [[ "$TESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 - [[ "$BUILD_RUN_ID" =~ ^[0-9]+$ ]] || exit 1 - [[ "$INSTALLER_RUN_ID" =~ ^[0-9]+$ ]] || exit 1 - gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$TESTED_SHA&per_page=100" > ci.json - jq --arg sha "$TESTED_SHA" -e ' - [.workflow_runs[] | select(.head_sha == $sha) | - select(.event == "pull_request" or .event == "push")] as $runs | - ($runs | length >= 10) and - ([$runs[] | select(.status != "completed" or .conclusion != "success")] | length == 0) - ' ci.json || { echo "All exact-SHA CI workflows must be successful." >&2; exit 1; } - TAG="fat-${TESTED_SHA:0:12}" - echo "PREVIEW_TAG=$TAG" >> "$GITHUB_ENV" - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Immutable preview already exists; never overwrite an asset." >&2; exit 1 - fi - - - name: Verify downloaded Windows portable is the actual CI-tested EXE - shell: bash - run: | - set -euo pipefail - mkdir -p staged/portable published - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN_ID" > build-run.json - gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name ARSAS-win-x64-portable-single-exe --dir staged/portable - mapfile -t files < <(find staged/portable -type f -name '*.exe') - [[ "${#files[@]}" -eq 1 ]] || { echo "Expected exactly one EXE." >&2; exit 1; } - python3 scripts/verify-fat-direct-exe.py \ - --run-json build-run.json --sha "$TESTED_SHA" --workflow "Build ARSAS" \ - --exe "${files[0]}" --output published/portable-provenance.json - cp "${files[0]}" "published/ARSAS-FAT-${TESTED_SHA:0:12}-win-x64-portable.exe" - - - name: Optionally verify separately successful exact-SHA installer - shell: bash - run: | - set -euo pipefail - if [[ "$INSTALLER_RUN_ID" == 0 ]]; then exit 0; fi - mkdir -p staged/installer - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$INSTALLER_RUN_ID" > installer-run.json - gh run download "$INSTALLER_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --pattern 'ARSAS-*-win-x64-installer' --dir staged/installer - mapfile -t files < <(find staged/installer -type f -name '*-setup.exe') - [[ "${#files[@]}" -eq 1 ]] || { echo "Expected exactly one installer." >&2; exit 1; } - python3 scripts/verify-fat-direct-exe.py \ - --run-json installer-run.json --sha "$TESTED_SHA" \ - --workflow "Validate ARSAS Windows installer" \ - --exe "${files[0]}" --output published/installer-provenance.json - cp "${files[0]}" "published/ARSAS-FAT-${TESTED_SHA:0:12}-win-x64-setup.exe" - - - name: Publish only approved non-production direct EXE preview assets - shell: bash - run: | - set -euo pipefail - (cd published && sha256sum ./*.exe > SHA256SUMS.txt && sha256sum -c SHA256SUMS.txt) - cat > published/NOT-PRODUCTION.txt < release-notes.md <> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index c0701d288..24d24c6a4 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -37,6 +37,7 @@ on: - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "installer/**" - "scripts/build-windows-installer.ps1" @@ -83,7 +84,7 @@ concurrency: jobs: package-ready: name: Wait for exact canonical package metadata - if: github.event_name != 'workflow_dispatch' + if: github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -185,6 +186,7 @@ jobs: if: >- ${{ always() && + (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'workflow_dispatch' || needs.package-ready.result == 'success') }} @@ -484,6 +486,53 @@ jobs: ) -Wait -PassThru if ($uninstall.ExitCode -ne 0) { throw "Uninstaller exited with code $($uninstall.ExitCode)." } + # Preserve the retired SMV preview workflow's protected-folder upgrade + # qualification inside the canonical installer workflow (no extra CI runner). + - name: Verify protected Program Files in-place DLL repair and uninstall + shell: powershell + run: | + $exe = ".\\ArIED61850Tester\\dist\\ARSAS-$env:APP_VERSION-win-x64-setup.exe" + if (-not (Test-Path $exe -PathType Leaf)) { throw 'Canonical installer missing.' } + $target = Join-Path $env:ProgramFiles 'ARSAS-FAT-ProtectedFolder-CI' + $dirArgument = '/DIR="' + $target + '"' + $params = @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-','/CLOSEAPPLICATIONS',$dirArgument) + $installed = $false + try { + $install = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Protected-folder install failed: $($install.ExitCode)." } + $installed = $true + if (-not (Test-Path (Join-Path $target 'ARSAS.exe') -PathType Leaf)) { + throw "Silent install did not use requested Program Files directory: $target" + } + $legacyAccessibility = Join-Path $target 'Accessibility.dll' + $publishedAccessibility = Join-Path $env:INSTALLER_INPUT_DIR 'Accessibility.dll' + if (-not (Test-Path $legacyAccessibility) -and -not (Test-Path $publishedAccessibility)) { + [System.IO.File]::WriteAllBytes($legacyAccessibility, [byte[]](65,82,83,65,83,83,45,79,76,68)) + Write-Host 'Seeded a legacy Accessibility.dll as a prior-install fixture.' + } + $repairName = if (Test-Path $publishedAccessibility -PathType Leaf) { 'Accessibility.dll' } else { 'AR.Iec61850.Transports.Npcap.dll' } + $repairPath = Join-Path $target $repairName + if (-not (Test-Path $repairPath -PathType Leaf)) { throw "Shipped upgrade-test DLL is missing: $repairName" } + $expectedHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash + [System.IO.File]::WriteAllBytes($repairPath, [byte[]](65,82,83,65,83,45,79,76,68,45,68,76,76)) + $upgrade = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru + if ($upgrade.ExitCode -ne 0) { throw "In-place Program Files upgrade failed: $($upgrade.ExitCode)." } + $actualHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { throw "Upgrade did not restore shipped DLL: $repairName" } + Write-Host "SMART_INNO_UPGRADE_PASS: Protected Program Files upgrade restored exact $repairName bytes." + foreach ($file in @('ARSAS.exe','AR.Iec61850.Transports.Npcap.dll','SharpPcap.dll','PacketDotNet.dll','Tools\\ArdIrec\\ardirec_bridge.dll')) { + if (-not (Test-Path (Join-Path $target $file) -PathType Leaf)) { throw "Installed runtime missing: $file" } + } + } + finally { + $uninstaller = Join-Path $target 'unins000.exe' + if ($installed -or (Test-Path $uninstaller -PathType Leaf)) { + if (-not (Test-Path $uninstaller -PathType Leaf)) { throw 'Uninstaller missing.' } + $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru + if ($uninstall.ExitCode -ne 0) { throw "Uninstall failed: $($uninstall.ExitCode)." } + } + } + - name: Create release checksums shell: powershell run: | diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index 0f8277e81..a7a4cfd31 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -137,11 +137,34 @@ jobs: [bool]$serverAt.physicalQualificationRequired -and [bool]$serverAt.noAutomaticMmsFailover -and $lock.ref -eq 'main' + # An exact typed-control trial on the existing staging ancestry: + # do not promote it, and never reinterpret it as physical R7/R10. + $typedControl = $lock.globalTypedControlStageTrial + $typedControlTrialValid = ( + $null -ne $typedControl -and + $lock.commit -ceq 'f75e323e9f8443aa20e0a3818a1d5d77255b2c0f' -and + $lock.ref -ceq 'main' -and + [int]$lock.sourcePullRequest -eq 192 -and + $typedControl.baseEngineCommit -ceq '9c7cb40364023a7b217e5ec31ab3105ba90142eb' -and + $typedControl.exactCommit -ceq $lock.commit -and + [int]$typedControl.enginePullRequest -eq 192 -and + [long]$typedControl.engineCiRun -eq 39909260816L -and + $typedControl.status -ceq 'engine-ci-success-consumer-trial-not-physical' -and + [bool]$typedControl.typedStatusReadRequired -and + [bool]$typedControl.sameDataObjectFeedbackRequired -and + [bool]$typedControl.ctlModelAndOperShapeRequired -and + [bool]$typedControl.sbowEnhancedSafetyRequired -and + [bool]$typedControl.noAutomaticOperate -and + [bool]$typedControl.physicalQualificationRequired -and + [bool]$typedControl.mainlinePromotionForbidden -and + $lock.p0CStageObservationTrial.exactCommit -ceq $typedControl.baseEngineCommit -and + $env:GITHUB_BASE_REF -ne 'main' -and $env:GITHUB_REF_NAME -ne 'main' + ) if ($null -ne $trial) { if ($trial.status -ne 'code-verified-not-physical' -or [int]$trial.issue -ne 144 -or [int]$trial.integrationPullRequest -ne 148 -or - -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid -or $m217KTrialValid -or $p0AIdentityTrialValid -or $p0BRcbTrialValid -or $p0CTypedReadTrialValid) -or + -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid -or $m217KTrialValid -or $p0AIdentityTrialValid -or $p0BRcbTrialValid -or $p0CTypedReadTrialValid -or $typedControlTrialValid) -or -not [bool]$trial.fullDiscoveryFallbackForbidden -or -not [bool]$trial.vendorBranchingForbidden -or -not [bool]$trial.physicalPromotionRequired) { diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 11ad9ab5a..bbab235b5 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -173,6 +173,29 @@ jobs: elseif ($coreRoutingTrialValid) { Write-Host 'P10.9F routing core accepted for CodeVerified staging CI ONLY; R7/R10 physical authority unchanged.' } + elseif ($lock.commit -ceq 'f75e323e9f8443aa20e0a3818a1d5d77255b2c0f') { + $typed = $lock.globalTypedControlStageTrial + if ($null -eq $typed -or + $lock.ref -cne 'main' -or + [int]$lock.sourcePullRequest -ne 192 -or + $typed.exactCommit -cne $lock.commit -or + $typed.baseEngineCommit -cne '9c7cb40364023a7b217e5ec31ab3105ba90142eb' -or + $lock.p0CStageObservationTrial.exactCommit -cne $typed.baseEngineCommit -or + [int]$typed.enginePullRequest -ne 192 -or + [long]$typed.engineCiRun -ne 39909260816L -or + $typed.status -cne 'engine-ci-success-consumer-trial-not-physical' -or + -not [bool]$typed.typedStatusReadRequired -or + -not [bool]$typed.sameDataObjectFeedbackRequired -or + -not [bool]$typed.ctlModelAndOperShapeRequired -or + -not [bool]$typed.sbowEnhancedSafetyRequired -or + -not [bool]$typed.noAutomaticOperate -or + -not [bool]$typed.physicalQualificationRequired -or + -not [bool]$typed.mainlinePromotionForbidden -or + $env:GITHUB_BASE_REF -eq 'main' -or $env:GITHUB_REF_NAME -eq 'main') { + throw 'Unqualified typed-control staging pin cannot replace physical R7/R10 authority.' + } + Write-Host 'Exact typed-control staging trial accepted for draft CI only; R7/R10 physical acceptance unchanged.' + } elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { if ([int]$lock.sourcePullRequest -notin @(134, 135) -or $lock.purpose -notmatch 'R7 physical SCL repair pin') { diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 95fe9fae7..b3aad602a 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -2,36 +2,27 @@ name: Smart Discovery Field Capture Build on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Services/Iec61850DeviceIdentityResolver.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + - "engine-patches/**" + - "scripts/*smart-discovery*" + - "scripts/enable-smart-discovery-capture.ps1" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - "evidence/smart-discovery-*.json" + - "evidence/interoperability-reference-target.json" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/actions/resolve-ariec-engine/**" + - ".github/workflows/smart-discovery-capture-build.yml" workflow_dispatch: permissions: @@ -57,6 +48,7 @@ jobs: if: >- ${{ always() && + (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || needs.manual-regression.result == 'success') }} diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml index 083a05b26..e639ef7cf 100644 --- a/.github/workflows/smart-discovery-golden-budget-lock.yml +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -2,36 +2,15 @@ name: Smart Discovery Golden Budget Lock on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-golden-lock.ps1" + - "scripts/verify-smart-discovery-golden-lock.ps1" + - "evidence/smart-discovery-golden-target.json" + - "evidence/smart-discovery-golden-lock.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-golden-budget-lock.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-golden-lock: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5e evidence-derived hard budget runs-on: windows-latest steps: diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml index 0b5d62cd0..4fd2345b0 100644 --- a/.github/workflows/smart-discovery-golden-provenance.yml +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -2,36 +2,15 @@ name: Smart Discovery Golden Provenance on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-golden-lock.ps1" + - "scripts/verify-smart-discovery-golden-lock.ps1" + - "evidence/smart-discovery-golden-target.json" + - "evidence/smart-discovery-golden-lock.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-golden-provenance.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-provenance: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5e capture build and target provenance runs-on: windows-latest steps: diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 46efab1b3..ae66488c0 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -2,36 +2,23 @@ name: Smart Discovery Mainline Readiness on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" + - "engines/ARIEC61850.lock.json" + - "scripts/enable-smart-discovery-capture.ps1" + - "scripts/*smart-discovery*" + - "evidence/interoperability-reference-target.json" + - "evidence/smart-discovery-*.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/workflows/smart-discovery-mainline-readiness.yml" workflow_dispatch: permissions: @@ -44,6 +31,7 @@ concurrency: jobs: mainline-readiness: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Require R10 physical authority before mainline review runs-on: windows-latest steps: diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index 70948c723..64c1afe8a 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -2,36 +2,22 @@ name: Smart Discovery Merge Execution Guard on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Models/MonitorModels.cs" + - "engines/ARIEC61850.lock.json" + - "scripts/new-smart-discovery-mainline-merge-manifest.ps1" + - "scripts/verify-smart-discovery-live-merge-preflight.ps1" + - "scripts/verify-smart-discovery-post-merge-production.ps1" + - "evidence/smart-discovery-mainline-merge-target.json" + - "evidence/smart-discovery-mainline-merge-manifest.json" + - "evidence/interoperability-reference-target.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - "docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md" + - ".github/workflows/smart-discovery-merge-execution-guard.yml" workflow_dispatch: permissions: @@ -44,6 +30,7 @@ concurrency: jobs: verify-merge-execution-contract: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5h ordered merge contract runs-on: ubuntu-latest timeout-minutes: 23 diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index fc6defb4b..2dd2d6bef 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -2,36 +2,23 @@ name: Smart Discovery Production Promotion Guard on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" + - "engines/ARIEC61850.lock.json" + - "scripts/enable-smart-discovery-capture.ps1" + - "scripts/*smart-discovery*" + - "evidence/interoperability-reference-target.json" + - "evidence/smart-discovery-*.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/workflows/smart-discovery-production-promotion.yml" workflow_dispatch: permissions: @@ -44,6 +31,7 @@ concurrency: jobs: verify-production-route: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify physical-proven Smart Discovery production parity runs-on: windows-latest timeout-minutes: 25 diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index 3f38fcb41..1d6827b98 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -2,36 +2,15 @@ name: Smart Discovery Repeat-Run Stability on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-repeat-run-authority.ps1" + - "scripts/finalize-smart-discovery-repeat-run-stability.ps1" + - "evidence/smart-discovery-repeat-run-target.json" + - "evidence/smart-discovery-golden-target.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-repeat-run-stability.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-repeat-run-stability: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5f repeat-run stability contract runs-on: windows-latest steps: diff --git a/.github/workflows/smv-direct-inno-preview.yml b/.github/workflows/smv-direct-inno-preview.yml deleted file mode 100644 index 5b6e30fcd..000000000 --- a/.github/workflows/smv-direct-inno-preview.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: Direct Inno SMV Preview EXE - -# One tested Windows runner; no ZIP artifact upload/download. -# Trusted, narrowly scoped branch only. Stable release is never modified. -on: - push: - branches: [feat/smv-sharp-latest-p0-20261022] - -permissions: - contents: write - -concurrency: - group: smv-inno-${{ github.ref }} - cancel-in-progress: true - -jobs: - direct-preview: - if: github.repository == 'masarray/arsas' && github.ref == 'refs/heads/feat/smv-sharp-latest-p0-20261022' - runs-on: windows-latest - timeout-minutes: 65 - defaults: - run: - shell: powershell - steps: - - name: Checkout exact source - uses: actions/checkout@v7 - with: - path: ArIED61850Tester - ref: ${{ github.sha }} - persist-credentials: false - fetch-depth: 1 - - - name: Validate SHA and immutable engine/native locks - run: | - $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() - if ($actual -ne $env:GITHUB_SHA.ToLowerInvariant()) { throw 'Source SHA mismatch.' } - $iec = Get-Content .\ArIED61850Tester\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json - $native = Get-Content .\ArIED61850Tester\engines\ARDIREC.lock.json -Raw | ConvertFrom-Json - if ($iec.repository -ne 'masarray/ARIEC61850' -or $iec.commit -notmatch '^[0-9a-f]{40}$' -or $native.repository -ne 'masarray/ardirec' -or $native.commit -notmatch '^[0-9a-f]{40}$' -or $native.bridge.abi -ne 1 -or $native.bridge.mode -ne 'native-only') { throw 'Engine lock invalid.' } - [xml]$props = Get-Content .\ArIED61850Tester\Directory.Build.props -Raw - [xml]$project = Get-Content .\ArIED61850Tester\ArIED61850Tester.csproj -Raw - $version = [string]$props.Project.PropertyGroup.Version - if ($version -notmatch '^\d+\.\d+\.\d+$' -or $version -ne [string]$project.Project.PropertyGroup.Version -or $version -ne (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim()) { throw 'Inconsistent app version.' } - $short = $actual.Substring(0,12) - "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "SMV_VERSION=$version-smvpreview.$short" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "SMV_TAG=smv-preview-$short" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC_SHA=$($iec.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARDIREC_SHA=$($native.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Enforce source and license boundaries - run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 - - - name: Validate Inno packaging PowerShell syntax before expensive build - run: | - $tokens = $null - $errors = $null - $scriptPath = (Resolve-Path .\ArIED61850Tester\scripts\build-windows-installer.ps1).Path - [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { throw ("Invalid installer PowerShell syntax: " + ($errors | ForEach-Object Message | Out-String)) } - - - name: Checkout pinned independent engines - run: | - git clone --quiet --filter=blob:none --no-checkout https://github.com/masarray/ARIEC61850.git ARIEC61850 - if ($LASTEXITCODE -ne 0) { throw 'ARIEC clone failed.' } - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC_SHA - if ($LASTEXITCODE -ne 0) { throw 'ARIEC pin fetch failed.' } - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC_SHA - if ($LASTEXITCODE -ne 0) { throw 'ARIEC pin checkout failed.' } - git clone --quiet --filter=blob:none --no-checkout https://github.com/masarray/ardirec.git ArdIrec - if ($LASTEXITCODE -ne 0) { throw 'Native clone failed.' } - git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_SHA - if ($LASTEXITCODE -ne 0) { throw 'Native pin fetch failed.' } - git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_SHA - if ($LASTEXITCODE -ne 0) { throw 'Native pin checkout failed.' } - if ((git -C .\ARIEC61850 rev-parse HEAD).Trim() -ne $env:ARIEC_SHA -or (git -C .\ArdIrec rev-parse HEAD).Trim() -ne $env:ARDIREC_SHA) { throw 'Pinned engine revision mismatch.' } - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 8.0.x - - - name: Restore, build and test full ARSAS and pinned engine - run: | - dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln - if ($LASTEXITCODE -ne 0) { throw 'Restore failed.' } - dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { throw 'Windows build failed.' } - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore - if ($LASTEXITCODE -ne 0) { throw 'Full application regression failed.' } - dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --filter 'FullyQualifiedName~TrustedSclIedIdentityMatcherTests|FullyQualifiedName~CanonicalLiveIedSclExporterTests' - if ($LASTEXITCODE -ne 0) { throw 'Engine regression failed.' } - - - name: Build and native-test exact pinned ArdIrec bridge - run: | - $bridge = [string]( & .\ArIED61850Tester\scripts\build-ardirec-bridge.ps1 -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" -OutputDirectory "$env:RUNNER_TEMP\smv-native" -BuildDirectory "$env:RUNNER_TEMP\smv-native-build" -CTestJunitPath "$env:RUNNER_TEMP\smv-native-ctest.xml" | Select-Object -Last 1 ) - $bridge = $bridge.Trim() - if (-not (Test-Path $bridge -PathType Leaf) -or -not (Test-Path "$env:RUNNER_TEMP\smv-native-ctest.xml")) { throw 'Native bridge or CTest proof missing.' } - "ARDIREC_BRIDGE=$bridge" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Publish canonical installer input in same runner - run: | - & .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version $env:ARSAS_VERSION -Runtime win-x64 -SingleFile $false -SelfContained $true -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" -ArdIrecBridgePath $env:ARDIREC_BRIDGE - if ($LASTEXITCODE -ne 0) { throw 'Installer input publish failed.' } - $folder = ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" - $native = Join-Path $folder 'Tools\ArdIrec\ardirec_bridge.dll' - if (-not (Test-Path $native) -or (Get-FileHash $native -Algorithm SHA256).Hash -ne (Get-FileHash $env:ARDIREC_BRIDGE -Algorithm SHA256).Hash) { throw 'Packaged native bridge differs from tested bridge.' } - if (Test-Path .\ArdIrec\LICENSE) { Copy-Item .\ArdIrec\LICENSE (Join-Path $folder 'Tools\ArdIrec\LICENSE.txt') -Force } - - - name: Ensure Inno Setup 6 - run: | - if (-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe')) { - choco install innosetup --no-progress --yes - if ($LASTEXITCODE -ne 0) { throw 'Inno Setup installation failed.' } - } - - - name: Compile tested Inno Setup executable directly (no ZIP) - run: | - & .\ArIED61850Tester\scripts\build-windows-installer.ps1 -Version $env:SMV_VERSION -Runtime win-x64 -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" -TestAssemblyPath "$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" -FixtureCfg "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" -LocusFixtureCfg "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - if ($LASTEXITCODE -ne 0) { throw 'Installer build or pre-package bridge smoke failed.' } - - - name: Silent install, installed-runtime smoke and uninstall - run: | - $exe = ".\ArIED61850Tester\dist\ARSAS-$env:SMV_VERSION-win-x64-setup.exe" - if (-not (Test-Path $exe -PathType Leaf)) { throw 'Installer missing.' } - # Reproduce the actual customer upgrade location and UAC mode. - # A protected Program Files folder exposes issues missed by RUNNER_TEMP. - $target = Join-Path $env:ProgramFiles 'ARSAS-SMV-Preview-CI' - # Start-Process joins argument elements: embed quotes inside /DIR so - # "C:\Program Files\..." reaches Inno Setup as exactly ONE parameter. - $dirArgument = '/DIR="' + $target + '"' - $params = @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-','/CLOSEAPPLICATIONS',$dirArgument) - $install = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru - if ($install.ExitCode -ne 0) { throw "Protected-folder install failed: $($install.ExitCode)." } - if (-not (Test-Path (Join-Path $target 'ARSAS.exe') -PathType Leaf)) { - throw "Silent install did not use requested Program Files directory: $target" - } - # Existing releases may include Accessibility.dll, while later releases - # do not. Test the exact shipped bytes of a real private DLL. - $legacyAccessibility = Join-Path $target 'Accessibility.dll' - $publishedAccessibility = Join-Path $env:GITHUB_WORKSPACE "ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64\Accessibility.dll" - if (-not (Test-Path $legacyAccessibility) -and -not (Test-Path $publishedAccessibility)) { - [System.IO.File]::WriteAllBytes($legacyAccessibility, [byte[]](65,82,83,65,83,83,45,79,76,68)) - Write-Host 'Seeded a legacy Accessibility.dll as a prior-install fixture.' - } - $repairName = if (Test-Path $publishedAccessibility -PathType Leaf) { 'Accessibility.dll' } else { 'AR.Iec61850.Transports.Npcap.dll' } - $repairPath = Join-Path $target $repairName - if (-not (Test-Path $repairPath -PathType Leaf)) { throw "Shipped upgrade-test DLL is missing: $repairName" } - $expectedHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash - [System.IO.File]::WriteAllBytes($repairPath, [byte[]](65,82,83,65,83,45,79,76,68,45,68,76,76)) - $upgrade = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru - if ($upgrade.ExitCode -ne 0) { throw "In-place Program Files upgrade failed: $($upgrade.ExitCode)." } - $actualHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash - if ($actualHash -ne $expectedHash) { throw "Upgrade did not restore shipped DLL: $repairName" } - Write-Host "SMART_INNO_UPGRADE_PASS: Protected Program Files upgrade restored exact $repairName bytes." - foreach ($file in @('ARSAS.exe','AR.Iec61850.Transports.Npcap.dll','SharpPcap.dll','PacketDotNet.dll','Tools\ArdIrec\ardirec_bridge.dll')) { - if (-not (Test-Path (Join-Path $target $file) -PathType Leaf)) { throw "Installed runtime missing: $file" } - } - $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $target 'Tools\ArdIrec\ardirec_bridge.dll' - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - dotnet vstest ".\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" '--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests' - if ($LASTEXITCODE -ne 0) { throw 'Installed native runtime smoke failed.' } - $uninstaller = Join-Path $target 'unins000.exe' - if (-not (Test-Path $uninstaller)) { throw 'Uninstaller missing.' } - $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru - if ($uninstall.ExitCode -ne 0) { throw "Uninstall failed: $($uninstall.ExitCode)." } - - - name: Publish immutable GitHub prerelease with direct EXE asset - env: - GH_TOKEN: ${{ github.token }} - run: | - $dist = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" - $exe = Join-Path $dist "ARSAS-$env:SMV_VERSION-win-x64-setup.exe" - if (-not (Test-Path $exe -PathType Leaf)) { throw 'Validated installer missing.' } - $name = Split-Path $exe -Leaf - $sha256 = (Get-FileHash $exe -Algorithm SHA256).Hash.ToLowerInvariant() - $sums = Join-Path $dist 'SHA256SUMS.txt' - "$sha256 $name" | Set-Content $sums -Encoding ascii - $provenance = Join-Path $dist 'SMV-PREVIEW-PROVENANCE.json' - @{sourceSha=$env:GITHUB_SHA;engineSha=$env:ARIEC_SHA;ardirecSha=$env:ARDIREC_SHA;installer=$name;installerSha256=$sha256;installedSmokePassed=$true;channel='engineering-preview'} | ConvertTo-Json | Set-Content $provenance -Encoding utf8 - $notes = Join-Path $dist 'SMV-PREVIEW-NOTES.md' - @("# ARSAS SMV Preview $env:SMV_VERSION",'','Direct Inno Setup EXE, without any workflow ZIP artifact.','Windows build, ARSAS regression, engine check, native CTest, silent install, bridge validation and uninstall passed.','Raw SMV channels are not calibrated voltage/current. Physical merging-unit acceptance remains pending.','Npcap must be installed separately. Authenticode signing is not asserted.',"Source: $env:GITHUB_SHA","SHA256: $sha256") | Set-Content $notes -Encoding utf8 - # gh release create refuses to create a second release with an existing - # tag. Do not probe a missing release with a failing native CLI command: - # Windows PowerShell 5.1 treats its stderr as a terminating error. - gh release create $env:SMV_TAG $exe $sums $provenance --repo $env:GITHUB_REPOSITORY --target $env:GITHUB_SHA --title "ARSAS SMV Preview $env:SMV_VERSION" --notes-file $notes --prerelease --latest=false - if ($LASTEXITCODE -ne 0) { throw 'Direct release upload failed.' } - $verified = gh release view $env:SMV_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isPrerelease,assets | ConvertFrom-Json - if ($LASTEXITCODE -ne 0 -or -not $verified.isPrerelease -or $verified.tagName -ne $env:SMV_TAG -or -not ($verified.assets | Where-Object { $_.name -eq $name })) { throw 'Release asset verification failed.' } - $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:SMV_TAG/$name" - "Direct Inno Setup EXE: $url" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "Installer SHA-256: $sha256" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - Write-Host "DIRECT_INSTALLER_EXE=$url" diff --git a/App.xaml b/App.xaml index b134cf642..d2fe57403 100644 --- a/App.xaml +++ b/App.xaml @@ -126,7 +126,7 @@ Static faces are intentional because WPF does not provide reliable variable-font axis selection. Ideal metrics + ClearType keep compact engineering text smooth. --> ./Assets/Fonts/#Inter - Cascadia Mono, Consolas + ./Assets/Fonts/#Inter + - - + - + + - + + - - - - - - - + + + + - - + + - + - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + - - - - - - + + diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs index bf11df221..e21b50b07 100644 --- a/ControlModelWriteWindow.xaml.cs +++ b/ControlModelWriteWindow.xaml.cs @@ -20,8 +20,22 @@ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label); private readonly SignalDefinition _signal; private readonly CancellationTokenSource _cancellation = new(); private bool _busy; + public bool VerifiedWriteOccurred { get; private set; } + public bool WriteAttempted { get; private set; } + public string WriteStatus { get; private set; } = ""; + public long WriteAssociation { get; private set; } private bool _liveModelReady; private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown; + // Read/GVA evidence belongs to one live MMS association and one immutable + // model instance. A reconnect cannot keep a previously enabled Write valid. + private long _inspectedAssociation; + private object? _inspectedLiveModel; + + private bool InspectionIsCurrent => + _inspectedLiveModel is not null && + ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel) && + _device.LiveValues.Association == _inspectedAssociation && + _device.IsConnected && !_cancellation.IsCancellationRequested; public ObservableCollection Choices { get; } = new() { @@ -52,7 +66,7 @@ private void ModelPicker_SelectionChanged(object sender, SelectionChangedEventAr private void UpdateWriteButton() { ReadButton.IsEnabled = !_busy; - WriteButton.IsEnabled = !_busy && _device.IsConnected && _liveModelReady && + WriteButton.IsEnabled = !_busy && _liveModelReady && InspectionIsCurrent && ModelPicker.SelectedItem is ModelChoice choice && choice.Model != _liveModel; } @@ -60,15 +74,29 @@ private async Task ReadModelAsync() { _busy = true; _liveModelReady = false; + _inspectedLiveModel = null; ModelPicker.IsEnabled = false; UpdateWriteButton(); StatusLabel.Text = "Reading current ctlModel…"; - FailureDetails.Visibility = Visibility.Collapsed; - FailureDetails.Text = string.Empty; + var association = _device.LiveValues.Association; + var model = _device.LiveDiscoveryModel; + ResultBanner.Background = System.Windows.Media.Brushes.Transparent; try { + if (!_device.IsConnected || model is null) + throw new InvalidOperationException("A connected IED with verified live model is required."); var capabilities = await _runtime.InspectControlAsync( _device.DeviceId, _signal, _cancellation.Token); + if (_cancellation.IsCancellationRequested || !IsLoaded) + return; + if (!_device.IsConnected || _device.LiveValues.Association != association || + !ReferenceEquals(_device.LiveDiscoveryModel, model)) + { + StatusLabel.Text = "IED connection or live model changed. Read again before Write."; + return; + } + _inspectedAssociation = association; + _inspectedLiveModel = model; _liveModel = capabilities.ControlModel; _liveModelReady = _liveModel != Iec61850ControlModelKind.Unknown && capabilities.EngineControlServiceAvailable; @@ -105,63 +133,77 @@ private async void Read_Click(object sender, RoutedEventArgs e) private async void Write_Click(object sender, RoutedEventArgs e) { - if (_busy || !_liveModelReady || !_device.IsConnected || + if (_busy || !_liveModelReady || !InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice selected || selected.Model == _liveModel) return; - // Exact expected value is confirmed again by ARIEC immediately before - // the one permitted CF Write. No automatic mode trials. + + // Clicking Write is the operator's explicit action. No second modal + // confirmation, no automatic retry and no IEC Control Operate. + // Revalidate the exact association/model/selection at dispatch time. var expected = _liveModel; + if (!InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice current || + current.Model != selected.Model || _liveModel != expected) + { + _liveModelReady = false; + UpdateWriteButton(); + ShowWriteFailure("IED connection or selection changed. Read again."); + return; + } + _busy = true; _liveModelReady = false; ModelPicker.IsEnabled = false; UpdateWriteButton(); - StatusLabel.Text = "Writing ctlModel and checking readback…"; + StatusLabel.Text = "Writing ctlModel…"; + ResultBanner.Background = System.Windows.Media.Brushes.Transparent; + string? failure = null; + bool verified = false; + WriteAttempted = true; + WriteAssociation = _inspectedAssociation; try { + // The pinned native dispatcher checks expected-old value and + // executes exactly one CF Write followed by MMS readback. var result = await ConfigureExactModelAsync(expected, selected.Model, _cancellation.Token); - if (!result.WriteAccepted || !result.ReadbackVerified || - (int)result.AfterModel != (int)selected.Model) - { - ShowWriteFailure(result.Detail, (int)selected.Model, (int)result.AfterModel, result.ReadbackVerified); - return; - } - - // The engine has completed the exact CF write and authoritative MMS readback. - // Do not issue a second inspection or keep the commissioning dialog open. - if (!_cancellation.IsCancellationRequested && IsLoaded) - DialogResult = true; + if (_cancellation.IsCancellationRequested || !IsLoaded) return; + verified = result.WriteAccepted && result.ReadbackVerified && + (int)result.AfterModel == (int)selected.Model; + if (verified) + VerifiedWriteOccurred = true; + else + failure = string.IsNullOrWhiteSpace(result.Detail) + ? "IED rejected the value or readback did not match." + : result.Detail; } catch (OperationCanceledException) { - ShowWriteFailure("Connection interrupted or operation cancelled. Read actual ctlModel before retrying."); + failure = "Connection interrupted; Write outcome is unknown."; } catch (Exception ex) { - ShowWriteFailure($"Write not verified: {ex.Message}. Read actual ctlModel."); + failure = ex.Message; } finally { + WriteStatus = verified + ? "ctlModel Write verified; refreshing Data Model…" + : "ctlModel Write ditolak / unknown: " + + (string.IsNullOrWhiteSpace(failure) ? "IED did not verify Write." : failure); _busy = false; - UpdateWriteButton(); + // Browser owns the post-Write MMS Read and SSOT publication. + // Close the editor immediately, including on rejected Writes. + if (IsLoaded) Close(); } } - private void ShowWriteFailure(string? detail, - int? expected = null, - int? actual = null, bool readbackVerified = false) + private void ShowWriteFailure(string? detail) { - if (_cancellation.IsCancellationRequested || !IsLoaded) - return; - - var reason = string.IsNullOrWhiteSpace(detail) - ? "IED did not provide a verified write result." - : detail.Trim(); - if (readbackVerified && expected.HasValue && actual.HasValue && expected != actual) - reason = $"Readback mismatch • Expected {expected.Value}, actual {actual.Value}. {reason}"; - - FailureDetails.Text = reason; - FailureDetails.Visibility = Visibility.Visible; - StatusLabel.Text = "Write not verified • no further write will be sent automatically."; + if (_cancellation.IsCancellationRequested || !IsLoaded) return; + var reason = string.IsNullOrWhiteSpace(detail) ? "IED did not verify Write." : detail.Trim(); + StatusLabel.Text = "Write ditolak / belum terverifikasi: " + + (reason.Length > 150 ? reason[..147] + "…" : reason); + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); } // Compile-time transport bridge; no reflection or ad-hoc MMS Write. diff --git a/IedBrowserWriteWindow.xaml b/IedBrowserWriteWindow.xaml new file mode 100644 index 000000000..78500fdc1 --- /dev/null +++ b/IedBrowserWriteWindow.xaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs new file mode 100644 index 000000000..3221de18d --- /dev/null +++ b/IedBrowserWriteWindow.xaml.cs @@ -0,0 +1,253 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Operator-initiated IEC 61850 MMS Write dialog, not an Operate command. +/// UI stages edits, runtime validates current association and ARIEC performs +/// the single native wire Write with independent before/after typed MMS reads. +/// +public partial class IedBrowserWriteWindow : Window +{ + private readonly Iec61850MonitorRuntime _runtime; + private readonly Iec61850MonitorDevice _device; + private readonly LiveIedModelDiscoveryDocument _model; + private readonly long _association; + private readonly CancellationTokenSource _lifetime = new(); + private bool _busy; + // A ctlModel Write may reach the wire even if cancellation hides its reply. + // Invalidate stale Command Dock permission on modal close in that case. + public HashSet PotentiallyChangedControlModels { get; } = + new(StringComparer.OrdinalIgnoreCase); + public long WriteAssociation => _association; + public List<(string Reference, string Fc)> AttemptedLeaves { get; } = new(); + public string WriteStatus { get; private set; } = ""; + public ObservableCollection Rows { get; } + + public IedBrowserWriteWindow( + Iec61850MonitorRuntime runtime, Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, IReadOnlyList rows) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _device = device ?? throw new ArgumentNullException(nameof(device)); + _model = model ?? throw new ArgumentNullException(nameof(model)); + ArgumentNullException.ThrowIfNull(rows); + _association = device.LiveValues.Association; + Rows = new ObservableCollection(rows); + foreach (var row in Rows) + row.PropertyChanged += (_, _) => UpdateWriteEnabled(); + InitializeComponent(); + DataContext = this; + IdentityText.Text = _device.Name + " · " + _device.IpAddress + + " · association " + _association + " · " + Rows.Count + + " live-verified configuration DA(s)"; + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + var work = SystemParameters.WorkArea; + Height = Math.Max(MinHeight, Math.Min(520, work.Height - 26)); + Width = Math.Max(MinWidth, Math.Min(940, work.Width - 26)); + // operator-friendly editor: preload bounded current values from this live + // association. Read-only traffic only; never write on opening. + await ReadRowsAsync(Rows.Take(24).ToArray()); + } + + private void UpdateWriteEnabled() + { + if (WriteButton is null) return; + WriteButton.IsEnabled = !_busy && CurrentAssociation && + Rows.Any(row => row.Selected && row.HasVerifiedRead && row.HasEdited); + } + + private bool CurrentAssociation => + _device.IsConnected && !_device.IsDemo && + !_lifetime.IsCancellationRequested && + ReferenceEquals(_device.LiveDiscoveryModel, _model) && + _device.LiveValues.Association == _association; + + private void SetBusy(bool busy) + { + _busy = busy; + ReadButton.IsEnabled = !busy; + UpdateWriteEnabled(); + WriteRowsGrid.IsEnabled = !busy; + } + + private async void Read_Click(object sender, RoutedEventArgs e) + { + if (_busy || !CurrentAssociation) return; + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, true); + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Row, true); + var selected = Rows.Where(row => row.Selected).ToArray(); + if (selected.Length == 0) + { + StatusText.Text = "Select the Data Attributes you want to read."; + return; + } + await ReadRowsAsync(selected); + } + + private async Task ReadRowsAsync(IedBrowserWriteRow[] selected) + { + if (_busy || !CurrentAssociation || selected.Length == 0) return; + SetBusy(true); + try + { + var i = 0; + foreach (var row in selected) + { + _lifetime.Token.ThrowIfCancellationRequested(); + if (!CurrentAssociation) + throw new InvalidOperationException("IED association changed. Reopen Write."); + StatusText.Text = "Reading " + (++i) + "/" + selected.Length + " · " + row.ShortName; + var observation = await _runtime.ReadBrowserConfigurationAsync( + _device.DeviceId, _model, _association, row, _lifetime.Token); + if (!CurrentAssociation) + throw new InvalidOperationException("IED association changed. Reopen Write."); + if (observation.IsSuccess && observation.Value is not null) + { + row.ApplyLiveRead(observation.Value); + row.Result = "Ready to edit"; + } + else + { + row.ExpireLiveRead(); + row.Current = "Read unavailable"; + row.Result = "Read failed: " + observation.Message; + } + } + StatusText.Text = "Choose a value, tick its row and click Write. No Write sent."; + } + catch (OperationCanceledException) + { + StatusText.Text = "Read cancelled; no Write attempted."; + } + catch (Exception ex) + { + StatusText.Text = "Read stopped: " + ex.Message; + } + finally { SetBusy(false); } + } + + private async void Write_Click(object sender, RoutedEventArgs e) + { + if (_busy || !CurrentAssociation) return; + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, true); + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Row, true); + var selected = Rows.Where(row => row.Selected).ToArray(); + if (selected.Length == 0) + { + StatusText.Text = "Select at least one DA; no Write was sent."; + return; + } + if (selected.Any(row => !row.HasVerifiedRead || !row.HasEdited || + row.LiveBeforeValue is null)) + { + StatusText.Text = "Read the selected DAs and choose a different value before Write."; + return; + } + if (selected.Length > 16) + { + StatusText.Text = "At most 16 explicitly staged DA(s) per supervised Write. Refine the selection."; + return; + } + + var staged = new List<(IedBrowserWriteRow Row, MmsDataValue Value)>(); + try + { + foreach (var row in selected) + { + var candidate = IedBrowserWritePlan.EncodeValue( + row.DataType, row.RequestedValue, row.Reference); + if (MmsDataCodec.Encode(candidate).AsSpan().SequenceEqual( + MmsDataCodec.Encode(row.LiveBeforeValue!))) + { + StatusText.Text = row.ShortName + ": unchanged value. No Write sent."; + return; + } + staged.Add((row, candidate)); + } + } + catch (Exception ex) when (ex is FormatException or OverflowException or NotSupportedException) + { + StatusText.Text = "Typed validation failed; zero Writes sent: " + ex.Message; + return; + } + + // The enabled Write button is explicit operator intent. Native + // configuration admission still verifies the same association, + // typed before-value and readback. No second modal prompt. + if (!CurrentAssociation) + { + StatusText.Text = "IED connection changed; no Write sent."; + return; + } + + SetBusy(true); + var passed = 0; + try + { + foreach (var (row, requested) in staged) + { + _lifetime.Token.ThrowIfCancellationRequested(); + if (!CurrentAssociation) + throw new InvalidOperationException("Live MMS association/model changed. No further Write."); + StatusText.Text = "Writing " + (passed + 1) + "/" + staged.Count + + " · " + row.Reference; + row.Result = "Write / readback pending…"; + row.ExpireLiveRead(); // each attempt requires a NEW explicit Read + if (row.IsControlModel) + PotentiallyChangedControlModels.Add(row.Reference[..^".ctlModel".Length]); + AttemptedLeaves.Add((row.Reference, row.FunctionalConstraint)); + var result = await _runtime.WriteBrowserConfigurationAsync( + _device.DeviceId, _model, _association, row, requested, _lifetime.Token); + row.Current = result.ReadbackVerified ? result.After : result.Before; + row.Result = result.NoChange ? "Unchanged · verified" : + result.Succeeded ? "Write + readback PASS" : + "NOT VERIFIED: " + result.Detail; + if (!result.Succeeded) + { + var reason = string.IsNullOrWhiteSpace(result.Detail) + ? "IED refused the requested value." : result.Detail; + StatusText.Text = "Write ditolak: " + + (reason.Length > 130 ? reason[..127] + "…" : reason) + + $" · {passed}/{staged.Count} verified. No automatic retry."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); + return; + } + passed++; + } + StatusText.Text = "Verified " + passed + "/" + staged.Count + + " configuration DA(s) using native MMS readback. No IEC Operate issued."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(232, 248, 237)); + } + catch (Exception ex) + { + StatusText.Text = "Write unresolved: " + ex.Message + + " · no automatic retry."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); + } + finally + { + WriteStatus = StatusText.Text; + SetBusy(false); + if (AttemptedLeaves.Count > 0 && IsLoaded) Close(); + } + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private void Window_Closing(object? sender, CancelEventArgs e) + => _lifetime.Cancel(); +} diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs index d08372f69..39c615c36 100644 --- a/IoListTestingWindow.CommandPanel.cs +++ b/IoListTestingWindow.CommandPanel.cs @@ -371,7 +371,7 @@ private FatCommandRowView BuildFatCommandRow(SignalDefinition signal) row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.55, GridUnitType.Star), MinWidth = 190 }); var reference = FatCommandText(signal.ObjectReference, 11.0, FontWeights.SemiBold); - reference.FontFamily = new FontFamily("Cascadia Mono, Consolas"); + reference.FontFamily = new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter"); reference.ToolTip = signal.ObjectReference; AddFatCommandCell(row, reference, 0); diff --git a/IoListTestingWindow.FatV2Ux.cs b/IoListTestingWindow.FatV2Ux.cs index 8f73887a1..440d579a8 100644 --- a/IoListTestingWindow.FatV2Ux.cs +++ b/IoListTestingWindow.FatV2Ux.cs @@ -171,7 +171,7 @@ private static DataGridTextColumn TextColumn( style.Setters.Add(new Setter(TextBlock.FontSizeProperty, 11.2)); style.Setters.Add(new Setter(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(68, 84, 107)))); if (monospace) - style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas"))); + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter"))); return new DataGridTextColumn { @@ -234,7 +234,7 @@ private DataGridTemplateColumn CreateValueColumn(FatValueSlot slot) ? nameof(IoTestPointPlan.Value1RelayTimestampText) : nameof(IoTestPointPlan.Value2RelayTimestampText))); timestamp.SetValue(TextBlock.FontSizeProperty, 8.8); - timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); diff --git a/IoListTestingWindow.P0BenchUx.cs b/IoListTestingWindow.P0BenchUx.cs index acac468e1..7ed4a6535 100644 --- a/IoListTestingWindow.P0BenchUx.cs +++ b/IoListTestingWindow.P0BenchUx.cs @@ -145,7 +145,7 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) ? nameof(IoTestPointPlan.Value1RelayTimestampText) : nameof(IoTestPointPlan.Value2RelayTimestampText))); timestamp.SetValue(TextBlock.FontSizeProperty, 8.8); - timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); diff --git a/IoListTestingWindow.PrintPreview.cs b/IoListTestingWindow.PrintPreview.cs index e968eaf0f..21081eb76 100644 --- a/IoListTestingWindow.PrintPreview.cs +++ b/IoListTestingWindow.PrintPreview.cs @@ -435,7 +435,7 @@ private static DataTemplate CenteredTemplate( text.SetValue(TextBlock.FontSizeProperty, fontSize); text.SetValue(TextBlock.FontWeightProperty, fontWeight); if (mono) - text.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + text.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); return new DataTemplate { VisualTree = text }; } diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml index 44e76c3e6..92ae8f411 100644 --- a/IpConnectWizardWindow.xaml +++ b/IpConnectWizardWindow.xaml @@ -1,8 +1,8 @@ @@ -11,12 +11,126 @@ + + + + + + - + @@ -24,74 +138,170 @@ - - + + - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + +