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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions .github/workflows/SonarGate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,59 @@ jobs:
env:
PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || 'BSVN_Lmt.Commons' }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
steps:
- name: Wait for the analysis, then check the quality gate
- name: Resolve the PR head commit (manual dispatch)
if: env.PR_HEAD_SHA == ''
env:
GITHUB_TOKEN: ${{ github.token }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' }
$pull = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/pulls/$($env:PR_NUMBER)" -Headers $headers
"PR_HEAD_SHA=$($pull.head.sha)" | Add-Content -Path $env:GITHUB_ENV
Write-Host "PR head commit: $($pull.head.sha)"

# Poll until SonarCloud publishes the quality-gate result for this PR
# (Automatic Analysis typically completes within a couple of minutes;
# allow up to ~10 minutes for busy moments).
$status = $null
for ($i = 0; $i -lt 60; $i++) {
$uri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER
$response = Invoke-RestMethod -Uri $uri
if ($response.projectStatus.status -and $response.projectStatus.status -ne 'NONE') {
$status = $response.projectStatus.status
- name: Wait for the analysis of this head commit, then check the quality gate
shell: pwsh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'

# The pull_request workflow starts right after the push, while
# SonarCloud Automatic Analysis is still running - and the
# quality-gate API meanwhile serves the PREVIOUS push's result.
# Reporting that stale result against the new commit is a false
# signal, so first wait for SonarCloud's own 'SonarCloud Code
# Analysis' check run of THIS head commit to complete (it appears
# exactly when the analysis finished), and only then read the gate
# status, which now reflects this commit's analysis.
$headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' }
$deadline = (Get-Date).AddMinutes(10)
$sonarCheck = $null
$attempt = 0
while ((Get-Date) -lt $deadline) {
$attempt++
$checks = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/commits/$($env:PR_HEAD_SHA)/check-runs?per_page=100" -Headers $headers
$sonarCheck = @($checks.check_runs | Where-Object -FilterScript { $_.name -eq 'SonarCloud Code Analysis' })
if ($sonarCheck.Count -gt 0 -and -not ($sonarCheck | Where-Object -FilterScript { $_.status -ne 'completed' })) {
break
}
Write-Host "Waiting for SonarCloud analysis of PR #$($env:PR_NUMBER) (attempt $($i + 1)/60)..."
Start-Sleep -Seconds 10
Write-Host "Waiting for SonarCloud to analyze commit $($env:PR_HEAD_SHA) (attempt $attempt/40)..."
Start-Sleep -Seconds 15
}

if (-not $sonarCheck -or $sonarCheck.Count -eq 0) {
throw "No SonarCloud Code Analysis check run appeared for commit $($env:PR_HEAD_SHA) within 10 minutes. Verify that SonarCloud Automatic Analysis is enabled and the branch is pushed (https://sonarcloud.io)."
}

# The analysis of this commit is complete: the gate API now
# reflects it.
$gateUri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER
$response = Invoke-RestMethod -Uri $gateUri
$status = $response.projectStatus.status
$reportUri = 'https://sonarcloud.io/summary/new_code?id={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER
switch ($status) {
'OK' {
Expand All @@ -49,7 +81,7 @@ jobs:
exit 1
}
default {
Write-Host "::error::No SonarCloud analysis appeared for PR #$($env:PR_NUMBER) within 10 minutes (last status: '$status'). Verify the project at https://sonarcloud.io (Automatic Analysis must be enabled and the branch pushed)."
Write-Host "::error::SonarCloud reported quality-gate status '$status' for PR #$($env:PR_NUMBER) (expected OK or ERROR). Verify the project at https://sonarcloud.io."
exit 1
}
}
24 changes: 17 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,17 @@ before:

- C# interop code lives in real .NET projects, one per module
(`src/<Module>/<Module>.csproj`, netstandard2.0, output to
`src/<Module>/lib/`), loaded via `RequiredAssemblies` in the manifest.
`src/<Module>/lib/`). The module loaders load the DLL through the shared
private `Import-BsnInteropAssembly` helper in Bsn.Common (invoked from each
psm1) - deliberately NOT via `RequiredAssemblies`: same-version reimports
(`Import-Module -Force`) reuse the loaded assembly, and cross-version
reimports fail with actionable guidance, because PowerShell cannot host two
versions of the same assembly in one process (issue #15).
`build.ps1` compiles every csproj with `dotnet build` before any task
runs; `lib/` and `obj/` are gitignored build outputs. Do not embed C# in
runs, stamping the assembly version from the module manifest
(`-p:Version=<ModuleVersion>`; guard tests assert the equality - all
shipped versions must carry distinct assembly versions);
`lib/` and `obj/` are gitignored build outputs. Do not embed C# in
here-strings inside .ps1 files.
- Shared helpers are factored into `src/Bsn.Common` (required by the other
modules via RequiredModules, the Az/VMware-style dependency chain):
Expand Down Expand Up @@ -184,11 +192,13 @@ before:
actions to full commit SHAs - codecov-action; Dependabot keeps SHA
pins updated).
- SonarCloud Gate (`.github/workflows/SonarGate.yml`): on every PR to
main, polls the SonarCloud quality-gate API for the PR's new code
(public read API, no token) and fails the check if the gate fails or
no analysis appears within 10 minutes. Make it (plus the CI matrix
and CodeQL checks) a required status check via branch protection to
enforce it before merges.
main, first waits for SonarCloud's own `SonarCloud Code Analysis` check
run of the PR head commit (the workflow starts while Automatic Analysis
is still running, and the quality-gate API would serve the previous
push's stale result), then fails the check unless the quality-gate API
returns OK (public SonarCloud read API, no SonarCloud token). Make it
(plus the CI matrix and CodeQL checks) a required status check via
branch protection to enforce it before merges.
- System tests (`src/Bsn.Host/tests/system/`): full end-to-end tests
that exercise the real recording engines (no mocks). Windows uses a
hidden-console runner process (CI has redirected stdio which breaks
Expand Down
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,75 @@ All notable changes to the modules in this repository are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and the modules adhere to [Semantic Versioning](https://semver.org/).

## [Bsn.Host 0.2.2] - 2026-11-07

### Fixed

- Recordings of byte-writing TUI applications (OpenCode, other
Go/Bubble Tea and Node TUIs, `cmd /c type`) are no longer corrupted
to codepage mojibake: the recorded session now bootstraps the console
codepage to UTF-8 before the command runs (the programmatic
`chcp 65001` via `[Console]::OutputEncoding`/`InputEncoding`).
Root cause: a fresh pseudo console starts with the OEM output
codepage (probe: 437), and OEM-codepage-decoding conhosts decode a
child's stdout BYTES with it - one box-drawing character
(U+2500, UTF-8 `E2 94 80`) arrived as three CP-decoded characters.
pwsh's own output was immune (`WriteConsoleW`), which is why
synthetic pwsh probes passed while real byte-writing TUIs were
corrupted. [#5](https://github.com/BSVN/Lmt.Commons/issues/5)
- Interop assembly loading is now managed by the module loader (via the
shared Bsn.Common helper) instead of the manifest's
`RequiredAssemblies`: re-importing with `Import-Module -Force` reuses
the already-loaded assembly instead of failing with
`FileLoadException`, and importing after `Update-Module` fails with
actionable guidance ("restart the session") instead of a raw
`FileLoadException`. A session restart is still required to switch
assembly versions in one process: PowerShell/.NET cannot host two
versions of the same assembly name, so a same-session update is a
platform limitation, not a module defect.
[#15](https://github.com/BSVN/Lmt.Commons/issues/15)
- The interop assembly is now stamped with the module version as its
`AssemblyVersion` at build time (build.ps1 passes
`-p:Version=<ModuleVersion from the psd1>`, which stays the single
source of truth). Previously every shipped module version carried the
default `AssemblyVersion` 1.0.0.0, so the loader could not tell an
already-loaded old version from the same version, silently reusing
mismatched interop code. Guard tests in Bsn.Host and
Bsn.SystemManagement assert the built DLL version equals the manifest
version.
[#15](https://github.com/BSVN/Lmt.Commons/issues/15)

### Added

- System tests reproducing each reported corruption scenario against
the real ConPTY engine: the OEM-codepage probe (the recorded session
must report codepage 65001), a raw byte-writing child (Go/Node TUI
output semantics) asserted character-for-character, a real OpenCode
TUI session (when installed), and metacharacter round-trip through
the real engine (issues #5 and #7).

## [Bsn.SystemManagement 0.2.1] - 2026-11-07

### Fixed

- Same interop assembly loading change as Bsn.Host 0.2.2 (loader-managed
via Bsn.Common 0.1.1, version-stamped assembly).
`Suspend-Computer` behavior is unchanged.
[#15](https://github.com/BSVN/Lmt.Commons/issues/15)

## [Bsn.Common 0.1.1] - 2026-11-07

### Added

- Private `Import-BsnInteropAssembly` helper used by the Bsn.* module
loaders (which pass their module root; the DLL is resolved from
`<module>/lib/<name>.dll`): reuses an already-loaded same-version
interop assembly on `Import-Module -Force`, and fails with actionable
guidance when a different version is already in the session.
Unit tests cover the missing-DLL, fresh-load, same-version reuse and
cross-version guidance paths.
[#15](https://github.com/BSVN/Lmt.Commons/issues/15)

## [Bsn.Host 0.2.1] - 2026-11-07

### Fixed
Expand Down
34 changes: 32 additions & 2 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,22 @@ function Invoke-ModuleCompile {

foreach ($project in $projects) {
Write-Host ("==> Compiling {0}" -f $project.BaseName)
& $dotnetCommand.Source build $project.FullName --configuration 'Release' --nologo --verbosity 'quiet'
# Stamp the interop assembly with the owning module's manifest version
# (the psd1 stays the single source of truth). Shipping every module
# version with the default AssemblyVersion 1.0.0.0 gives all module
# versions an identical assembly identity, so Import-Module -Force
# after Update-Module fails with FileLoadException (.NET refuses a
# second assembly with the same identity in one process).
# https://github.com/BSVN/Lmt.Commons/issues/15
$manifestPath = Join-Path -Path $project.DirectoryName -ChildPath ($project.BaseName + '.psd1')
$buildArguments = @('build', $project.FullName, '--configuration', 'Release', '--nologo', '--verbosity', 'quiet')
if (Test-Path -LiteralPath $manifestPath) {
$moduleVersion = (Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion
if ($moduleVersion) {
$buildArguments += ('-p:Version=' + $moduleVersion)
}
}
& $dotnetCommand.Source @buildArguments
if ($LASTEXITCODE -ne 0) {
throw ("dotnet build failed for '{0}'." -f $project.FullName)
}
Expand Down Expand Up @@ -180,7 +195,22 @@ $taskMap = @{
Import-Module -Name PSScriptAnalyzer -ErrorAction 'Stop'
foreach ($module in (Get-BuildModule)) {
Write-Host ("==> Analyzing {0}" -f $module.Name)
$findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop')
# PSScriptAnalyzer imports the module it analyzes (that is how the
# test-harness FileLoadException workaround below historically
# appeared). Without the repo src prepended, module names resolve
# to the newest GALLERY copy, so the analysis silently runs against
# published code and leaves that foreign assembly loaded in this
# process - which then breaks the repo tests' interop assembly
# loading (issue #15). Analyze the repo's own modules instead.
$sourceDirectory = Join-Path -Path $repoRoot -ChildPath 'src'
$previousModulePath = $env:PSModulePath
$env:PSModulePath = '{0}{1}{2}' -f $sourceDirectory, [System.IO.Path]::PathSeparator, $env:PSModulePath
try {
$findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop')
}
finally {
$env:PSModulePath = $previousModulePath
}
if ($findings.Count -gt 0) {
$findings | Format-Table -AutoSize | Out-String -Width 200 | Write-Host -ForegroundColor Yellow
throw ('{0} failed PSScriptAnalyzer with {1} finding(s).' -f $module.Name, $findings.Count)
Expand Down
4 changes: 2 additions & 2 deletions src/Bsn.Common/Bsn.Common.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'Bsn.Common.psm1'
ModuleVersion = '0.1.0'
ModuleVersion = '0.1.1'
GUID = '4a2f0a3f-6d21-4c7e-9f42-8b3d1e5c9a76'
Author = 'BSVN'
CompanyName = 'BSVN'
Expand All @@ -18,7 +18,7 @@
Tags = @('BSVN', 'Lmt')
LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE'
ProjectUri = 'https://github.com/BSVN/Lmt.Commons'
ReleaseNotes = 'Initial release: Get-BsnOperatingSystem with the BsnOperatingSystem enum.'
ReleaseNotes = 'Adds the private Import-BsnInteropAssembly helper used by the Bsn.* module loaders: reuses an already-loaded same-version interop assembly on Import-Module -Force, and fails with actionable guidance when a different version is already in the session (issue #15).'
}
}
}
81 changes: 81 additions & 0 deletions src/Bsn.Common/Bsn.Common.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,84 @@ function Get-BsnOperatingSystem {
return [BsnOperatingSystem]::Unknown
}
}

<#
.SYNOPSIS
Loads a module interop assembly with same-session reimport tolerance.

.DESCRIPTION
PowerShell's assembly loading dedupes by simple assembly name, so a
module that ships its DLL through RequiredAssemblies (or plain
Add-Type) cannot be re-imported from a different installed version in
the same session: the second load fails with FileLoadException
("Assembly with same name is already loaded").

The Bsn.* modules load their interop assemblies through this helper
instead of RequiredAssemblies. The DLL is resolved from the calling
module's own lib directory ('<ModuleRoot>/lib/<AssemblyName>.dll'):
- when an assembly with the same name AND version is already loaded
(Import-Module -Force of the same version), it is reused silently;
- when a DIFFERENT version is already loaded (Import-Module after
Update-Module), it fails with actionable guidance, because
PowerShell cannot host two versions of the same assembly in one
process - this is why the interop assemblies are stamped with the
module version at build time (build.ps1 passes -p:Version from the
manifest; guard tests assert the equality);
- otherwise the assembly is loaded from the resolved path.

https://github.com/BSVN/Lmt.Commons/issues/15

Kept private (consumer module loaders invoke it through this module's
scope) so it does not extend Bsn.Common's public command surface.
#>
function Import-BsnInteropAssembly {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ModuleRoot,

[Parameter(Mandatory = $true)]
[string]$AssemblyName
)

$assemblyPath = Join-Path -Path (Join-Path -Path $ModuleRoot -ChildPath 'lib') -ChildPath ($AssemblyName + '.dll')
if (-not (Test-Path -LiteralPath $assemblyPath)) {
throw ("The interop assembly is missing at '{0}'. Run './build.ps1' to build it, or reinstall the module." -f $assemblyPath)
}

$fileVersion = [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).Version
$loadedAssemblies = @(Get-BsnLoadedInteropAssembly -AssemblyName $AssemblyName)
if ($loadedAssemblies.Count -gt 0) {
$loadedVersion = $loadedAssemblies[0].GetName().Version
if ($loadedVersion -ne $fileVersion) {
throw ("Cannot load {0} version {1}: version {2} is already loaded in this session (from '{3}'). PowerShell cannot host two versions of the same assembly in one process; restart the session (or open a new one) and import the module again. https://github.com/BSVN/Lmt.Commons/issues/15" -f $AssemblyName, $fileVersion, $loadedVersion, $loadedAssemblies[0].Location)
}

# Same version already loaded (e.g. Import-Module -Force): reuse it.
return
}

Add-Type -Path $assemblyPath
}

<#
.SYNOPSIS
Lists the loaded assemblies with the given simple assembly name.

.DESCRIPTION
Private seam between Import-BsnInteropAssembly and the AppDomain.
Isolated into a function so unit tests can Mock it (-ModuleName
Bsn.Common) to simulate a differently-versioned assembly already
being loaded - the static AppDomain state itself cannot be faked.
#>
function Get-BsnLoadedInteropAssembly {
[CmdletBinding()]
[OutputType('System.Reflection.Assembly')]
param(
[Parameter(Mandatory = $true)]
[string]$AssemblyName
)

@([System.AppDomain]::CurrentDomain.GetAssemblies() |
Where-Object { $_.GetName().Name -eq $AssemblyName })
}
Loading
Loading