From 9715102a1009bb86b411e63d7cc3efa0e56212ec Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 19:59:19 +0700 Subject: [PATCH 01/10] Analyze the complete solution with CodeQL --- .github/workflows/codeql.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 232a54a..f51ef92 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,7 +14,7 @@ permissions: jobs: analyze: - name: Analyze C# + name: Analyze C# solution runs-on: windows-latest steps: - name: Checkout @@ -31,10 +31,11 @@ jobs: languages: csharp build-mode: manual - - name: Build - run: | - dotnet restore src/ARSVIN/ARSVIN.csproj - dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore + - name: Restore solution + run: dotnet restore ARSVIN.sln + + - name: Build solution + run: dotnet build ARSVIN.sln -c Release --no-restore - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 From 9e90b4d4469b4944f93a079b394c5a4a926bae16 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 19:59:30 +0700 Subject: [PATCH 02/10] Cover central NuGet packages with Dependabot --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a2790aa..136403a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 updates: - package-ecosystem: nuget - directory: /src/ARSVIN + directory: / schedule: interval: weekly open-pull-requests-limit: 5 From 9864f743528c65aa3622dac6e669de8409c110f5 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 19:59:54 +0700 Subject: [PATCH 03/10] Make the installer safe for silent automation --- installer/ARSVIN.iss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/installer/ARSVIN.iss b/installer/ARSVIN.iss index b20e7b3..d5164d2 100644 --- a/installer/ARSVIN.iss +++ b/installer/ARSVIN.iss @@ -84,14 +84,15 @@ end; procedure CurStepChanged(CurStep: TSetupStep); begin - if (CurStep = ssPostInstall) and (not NpcapInstalled()) then + if (CurStep = ssPostInstall) and (not WizardSilent) and (not NpcapInstalled()) then begin - MsgBox( + SuppressibleMsgBox( 'ARSVIN was installed successfully.' + #13#10 + #13#10 + 'Live IEC 61850 Sampled Values capture and transmission require Npcap. ' + 'Install Npcap separately from its official website before using live network features.', mbInformation, - MB_OK + MB_OK, + IDOK ); end; end; From 69f86c977b92f7ae18ac151a57af35797af2784c Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:00:44 +0700 Subject: [PATCH 04/10] Validate the complete release pipeline on pull requests --- .github/workflows/release.yml | 118 ++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1f70e2..08f1536 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,20 @@ on: push: tags: - 'v*.*.*' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/release.yml' + - 'installer/**' + - 'scripts/publish-release.ps1' + - 'publish-win-x64.ps1' + - 'build.ps1' + - 'src/**' + - 'tests/**' + - 'ARSVIN.sln' + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'global.json' workflow_dispatch: inputs: version: @@ -12,10 +26,10 @@ on: default: '0.0.0-dev' permissions: - contents: write + contents: read concurrency: - group: release-${{ github.ref }} + group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: @@ -23,9 +37,12 @@ env: DOTNET_NOLOGO: '1' jobs: - release: - name: Portable EXEs and installer + package: + name: Validate portable EXEs and installer runs-on: windows-latest + outputs: + version: ${{ steps.version.outputs.value }} + artifact_name: ${{ steps.version.outputs.artifact_name }} steps: - name: Checkout @@ -47,19 +64,24 @@ jobs: if ('${{ github.ref_type }}' -eq 'tag') { $version = '${{ github.ref_name }}' -replace '^[vV]', '' } - else { + elseif ('${{ github.event_name }}' -eq 'workflow_dispatch') { $version = '${{ inputs.version }}' if ([string]::IsNullOrWhiteSpace($version)) { $version = '0.0.0-dev' } $version = $version -replace '^[vV]', '' } + else { + $version = "0.0.0-pr.$env:GITHUB_RUN_NUMBER" + } if ($version -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') { throw "Unsupported release version: $version" } + $artifactName = "ARSVIN-$version-windows-x64" "value=$version" >> $env:GITHUB_OUTPUT + "artifact_name=$artifactName" >> $env:GITHUB_OUTPUT Write-Host "Release version: $version" - name: Build and test @@ -100,6 +122,70 @@ jobs: throw "Inno Setup failed with exit code $LASTEXITCODE." } + - name: Validate release artifacts + shell: pwsh + run: | + $releaseRoot = (Resolve-Path '.\artifacts\release').Path + $expected = @( + 'ARSVIN-Publisher-win-x64.exe', + 'ArSubsv-Subscriber-win-x64.exe', + 'ARSVIN-Suite-Setup-win-x64.exe', + 'ARSVIN-win-x64-portable.zip' + ) + + foreach ($name in $expected) { + $path = Join-Path $releaseRoot $name + if (-not (Test-Path $path -PathType Leaf)) { + throw "Expected release artifact was not found: $path" + } + + if ((Get-Item $path).Length -le 0) { + throw "Release artifact is empty: $path" + } + } + + Get-ChildItem $releaseRoot -File | + Select-Object Name, Length | + Sort-Object Name | + Format-Table -AutoSize + + - name: Smoke-test silent installer + shell: pwsh + run: | + $setup = (Resolve-Path '.\artifacts\release\ARSVIN-Suite-Setup-win-x64.exe').Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARSVIN-SmokeInstall' + + if (Test-Path $installDir) { + Remove-Item $installDir -Recurse -Force + } + + & $setup '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' "/DIR=$installDir" + if ($LASTEXITCODE -ne 0) { + throw "Silent installer failed with exit code $LASTEXITCODE." + } + + $installedFiles = @( + 'ARSVIN.exe', + 'ArSubsv.exe', + 'README.md', + 'LICENSE.txt', + 'VERSION.txt', + 'unins000.exe' + ) + + foreach ($name in $installedFiles) { + $path = Join-Path $installDir $name + if (-not (Test-Path $path -PathType Leaf)) { + throw "Installer smoke test could not find: $path" + } + } + + $uninstaller = Join-Path $installDir 'unins000.exe' + & $uninstaller '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' + if ($LASTEXITCODE -ne 0) { + throw "Silent uninstaller failed with exit code $LASTEXITCODE." + } + - name: Generate SHA-256 checksums shell: pwsh run: | @@ -118,12 +204,16 @@ jobs: [System.Text.UTF8Encoding]::new($false) ) + if ($lines.Count -ne 4) { + throw "Expected four checksummed release artifacts, found $($lines.Count)." + } + Get-Content (Join-Path $releaseRoot 'SHA256SUMS.txt') - name: Upload release workflow artifact uses: actions/upload-artifact@v7 with: - name: ARSVIN-${{ steps.version.outputs.value }}-windows-x64 + name: ${{ steps.version.outputs.artifact_name }} path: | artifacts/release/*.exe artifacts/release/*.zip @@ -131,8 +221,22 @@ jobs: if-no-files-found: error retention-days: 30 + release: + name: Publish GitHub Release + if: github.ref_type == 'tag' + needs: package + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download validated release artifact + uses: actions/download-artifact@v8 + with: + name: ${{ needs.package.outputs.artifact_name }} + path: artifacts/release + - name: Create GitHub Release - if: github.ref_type == 'tag' uses: softprops/action-gh-release@v3 with: generate_release_notes: true From d690969f8807f75fe05a422a6b2851f81ea525b7 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:01:18 +0700 Subject: [PATCH 05/10] Add deterministic public site validation --- scripts/validate-public-site.ps1 | 122 +++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 scripts/validate-public-site.ps1 diff --git a/scripts/validate-public-site.ps1 b/scripts/validate-public-site.ps1 new file mode 100644 index 0000000..5df7e19 --- /dev/null +++ b/scripts/validate-public-site.ps1 @@ -0,0 +1,122 @@ +param( + [string] $SiteRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'site') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-True { + param( + [Parameter(Mandatory)] + [bool] $Condition, + [Parameter(Mandatory)] + [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +$sitePath = (Resolve-Path $SiteRoot).Path +$indexPath = Join-Path $sitePath 'index.html' +$manifestPath = Join-Path $sitePath 'site.webmanifest' +$sitemapPath = Join-Path $sitePath 'sitemap.xml' +$robotsPath = Join-Path $sitePath 'robots.txt' + +$requiredFiles = @( + $indexPath, + (Join-Path $sitePath 'styles.css'), + $manifestPath, + $sitemapPath, + $robotsPath +) + +foreach ($file in $requiredFiles) { + Assert-True (Test-Path $file -PathType Leaf) "Required public-site file is missing: $file" +} + +$html = Get-Content $indexPath -Raw + +$requiredHtmlPatterns = @( + ']*>(.*?)' +$jsonLdMatches = [regex]::Matches( + $html, + $jsonLdPattern, + [Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [Text.RegularExpressions.RegexOptions]::Singleline +) +Assert-True ($jsonLdMatches.Count -gt 0) 'No JSON-LD structured data blocks were found.' + +foreach ($match in $jsonLdMatches) { + $null = $match.Groups[1].Value | ConvertFrom-Json +} + +$attributeMatches = [regex]::Matches( + $html, + '(?:src|href)=["'']([^"'']+)["'']', + [Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +$missingLocalReferences = [System.Collections.Generic.List[string]]::new() +foreach ($match in $attributeMatches) { + $reference = $match.Groups[1].Value.Trim() + if ( + [string]::IsNullOrWhiteSpace($reference) -or + $reference.StartsWith('#') -or + $reference -match '^(?i:https?:|mailto:|data:|javascript:)' + ) { + continue + } + + $relativePath = ($reference -split '[?#]', 2)[0] + if ([string]::IsNullOrWhiteSpace($relativePath)) { + continue + } + + $normalizedPath = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) + $resolvedPath = Join-Path $sitePath $normalizedPath + if (-not (Test-Path $resolvedPath -PathType Leaf)) { + $missingLocalReferences.Add($reference) + } +} + +Assert-True ($missingLocalReferences.Count -eq 0) "Missing local site references: $($missingLocalReferences -join ', ')" + +$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json +Assert-True (-not [string]::IsNullOrWhiteSpace($manifest.name)) 'The web manifest name is missing.' +Assert-True (-not [string]::IsNullOrWhiteSpace($manifest.short_name)) 'The web manifest short_name is missing.' +Assert-True ($manifest.icons.Count -gt 0) 'The web manifest does not declare any icons.' + +foreach ($icon in $manifest.icons) { + $iconPath = Join-Path $sitePath ($icon.src.Replace('/', [IO.Path]::DirectorySeparatorChar)) + Assert-True (Test-Path $iconPath -PathType Leaf) "Web-manifest icon is missing: $($icon.src)" +} + +[xml] $sitemap = Get-Content $sitemapPath -Raw +$location = $sitemap.urlset.url.loc +Assert-True ($location -eq 'https://masarray.github.io/arsvin/') "Unexpected sitemap canonical URL: $location" + +$robots = Get-Content $robotsPath -Raw +Assert-True ($robots -match 'Sitemap:\s*https://masarray\.github\.io/arsvin/sitemap\.xml') 'robots.txt does not reference the public sitemap.' + +Write-Host "Public site validation passed: $sitePath" From 776a350dc17f6d89676a200dc4f29d7117fc8035 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:01:34 +0700 Subject: [PATCH 06/10] Validate the landing page in CI --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a51b3..12ffbd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: build-test: - name: Build and test + name: Build, test, and validate site runs-on: windows-latest steps: - name: Checkout @@ -23,6 +23,10 @@ jobs: with: dotnet-version: 8.0.x + - name: Validate public site + shell: pwsh + run: .\scripts\validate-public-site.ps1 + - name: Restore Publisher run: dotnet restore src/ARSVIN/ARSVIN.csproj From 7599beb365ac156e9cf25259b2a2b860de6645a6 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:02:17 +0700 Subject: [PATCH 07/10] Use accurate release reproducibility wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4212361..1bc17b4 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The tools are intentionally separated. Publisher evidence proves what the PC gen ## Release downloads -Every tagged release builds reproducible Windows x64 artifacts: +Every tagged release builds validated Windows x64 artifacts with stable filenames: | Artifact | Use | |---|---| From 37a0d4c70a9cd97342d2bfb0bbeaab5398e780c2 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:02:35 +0700 Subject: [PATCH 08/10] Document installer build tooling --- THIRD_PARTY_NOTICES.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3a61b09..e051182 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -This file summarizes third-party components that ARSVIN depends on directly or expects users to install for live packet capture and transmission. +This file summarizes third-party components that ARSVIN depends on directly, includes in self-contained releases, or expects users to install for live packet capture and transmission. ## SharpPcap @@ -22,3 +22,10 @@ Npcap is not bundled in this repository or silently installed by the ARSVIN inst ## .NET and WPF ARSVIN is built with .NET and WPF. Self-contained release binaries include applicable .NET runtime components. See Microsoft documentation and license terms for those components. + +## Inno Setup + +- Purpose: Build tooling and installer runtime used to create `ARSVIN-Suite-Setup-win-x64.exe` +- Project: https://jrsoftware.org/isinfo.php + +Inno Setup is not required to run the portable applications. Its license and third-party component notices apply to the generated installer as described by the Inno Setup project. From 2ffcd1b944895f4a87fd625304ea16654f24c1d3 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:03:20 +0700 Subject: [PATCH 09/10] Document release validation and installer smoke tests --- docs/build-and-release.md | 40 +++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 8a51d6e..9463e9f 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -31,6 +31,14 @@ The script restores, builds, and tests: External command exit codes are checked, so CI and local builds stop immediately when a `dotnet` command fails. +## Validate the public site + +```powershell +.\scripts\validate-public-site.ps1 +``` + +The validator checks required landing-page files, local asset references, JSON-LD blocks, web-manifest icons, sitemap canonical URL, release download filenames, and the sitemap reference in `robots.txt`. + ## Build portable release artifacts ```powershell @@ -91,12 +99,29 @@ The installer: - offers an optional Publisher desktop shortcut, - includes an uninstaller, - preserves Apache-2.0 and third-party notices, -- warns when Npcap is not detected. +- warns interactively when Npcap is not detected, +- supports unattended installation and removal without an Npcap message box. ## GitHub Actions release flow Workflow: `.github/workflows/release.yml` +### Pull-request validation + +When release tooling, installer definitions, project files, or build configuration change, the workflow runs on the pull request and: + +1. restores, builds, and tests the solution, +2. publishes both portable single-file applications, +3. creates the portable suite ZIP, +4. compiles the Inno Setup installer, +5. checks all expected artifact names and non-empty files, +6. silently installs the suite into a temporary directory, +7. verifies Publisher, Subscriber, documentation, version file, and uninstaller, +8. silently uninstalls the temporary installation, +9. generates checksums and uploads a private workflow artifact. + +A pull-request run never creates a public GitHub Release. + ### Tagged release Create and push a semantic version tag: @@ -106,16 +131,7 @@ git tag v0.3.0 git push origin v0.3.0 ``` -The workflow then: - -1. restores, builds, and tests the solution, -2. publishes both self-contained single-file applications, -3. creates the portable suite ZIP, -4. installs Inno Setup on the Windows runner, -5. compiles the suite installer, -6. generates `SHA256SUMS.txt`, -7. uploads the files as a workflow artifact, -8. creates a GitHub Release and attaches all public artifacts. +The workflow repeats the validated packaging path, downloads the validated workflow artifact in a separate least-privilege release job, and publishes all public files to a GitHub Release. ### Manual artifact build @@ -151,7 +167,7 @@ When a trusted code-signing certificate becomes available, sign the portable exe Before tagging a public release: -- Confirm `main` CI and CodeQL are green. +- Confirm `main` CI, release validation, and CodeQL are green. - Test Publisher dry run. - Test live publishing only on an isolated lab link. - Test Subscriber live capture and PCAP import. From cec483cf667152484a637b2ebd3765e4957115b2 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 20:07:44 +0700 Subject: [PATCH 10/10] Improve installer smoke-test diagnostics and process handling --- .github/workflows/release.yml | 51 ++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08f1536..c0ef3c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,23 +154,41 @@ jobs: run: | $setup = (Resolve-Path '.\artifacts\release\ARSVIN-Suite-Setup-win-x64.exe').Path $installDir = Join-Path $env:RUNNER_TEMP 'ARSVIN-SmokeInstall' + $installLog = Join-Path $env:RUNNER_TEMP 'arsvin-install.log' + $uninstallLog = Join-Path $env:RUNNER_TEMP 'arsvin-uninstall.log' if (Test-Path $installDir) { Remove-Item $installDir -Recurse -Force } + Remove-Item $installLog, $uninstallLog -Force -ErrorAction SilentlyContinue + + $installArgs = @( + '/VERYSILENT', + '/SUPPRESSMSGBOXES', + '/NORESTART', + "/DIR=`"$installDir`"", + "/LOG=`"$installLog`"" + ) + $installProcess = Start-Process -FilePath $setup -ArgumentList $installArgs -Wait -PassThru - & $setup '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' "/DIR=$installDir" - if ($LASTEXITCODE -ne 0) { - throw "Silent installer failed with exit code $LASTEXITCODE." + if (Test-Path $installLog) { + Get-Content $installLog + } + if ($installProcess.ExitCode -ne 0) { + throw "Silent installer failed with exit code $($installProcess.ExitCode)." } + Write-Host 'Installed files:' + Get-ChildItem $installDir -Recurse -Force | + Select-Object FullName, Length | + Format-Table -AutoSize + $installedFiles = @( 'ARSVIN.exe', 'ArSubsv.exe', 'README.md', 'LICENSE.txt', - 'VERSION.txt', - 'unins000.exe' + 'VERSION.txt' ) foreach ($name in $installedFiles) { @@ -180,10 +198,25 @@ jobs: } } - $uninstaller = Join-Path $installDir 'unins000.exe' - & $uninstaller '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' - if ($LASTEXITCODE -ne 0) { - throw "Silent uninstaller failed with exit code $LASTEXITCODE." + $uninstaller = Get-ChildItem $installDir -Filter 'unins*.exe' -File | + Select-Object -First 1 + if (-not $uninstaller) { + throw "Installer smoke test could not find an uninstaller in: $installDir" + } + + $uninstallArgs = @( + '/VERYSILENT', + '/SUPPRESSMSGBOXES', + '/NORESTART', + "/LOG=`"$uninstallLog`"" + ) + $uninstallProcess = Start-Process -FilePath $uninstaller.FullName -ArgumentList $uninstallArgs -Wait -PassThru + + if (Test-Path $uninstallLog) { + Get-Content $uninstallLog + } + if ($uninstallProcess.ExitCode -ne 0) { + throw "Silent uninstaller failed with exit code $($uninstallProcess.ExitCode)." } - name: Generate SHA-256 checksums