What is missing
The self-hosted runner image sets no Windows account lockout threshold, so a SQL login can never be locked out on a CI runner. Nothing under .github/runners/ configures one - there is no net accounts, no secedit and no LockoutBadCount anywhere in the image build.
Without a threshold, LOGINPROPERTY(login, 'IsLocked') stays 0 no matter how many logons fail, so no test can cover unlocking a login.
Why this matters now
tests/Set-DbaLogin.Tests.ps1 contains a test that locks a login out and unlocks it three different ways. It has been skipped for years behind It -Skip "Unlock" with a note saying it "makes assumptions the password policy configuration is enabled for the Windows OS". Enabling it found a real bug in Set-DbaLogin, which is fixed in a separate PR - but that PR has to leave the test enabled and failing on CI until the runners have a threshold, because a skipped test would hide the regression again.
Which lane needs it
Only the self-hosted Azure lane. Set-DbaLogin.Tests.ps1 uses $TestConfig.InstanceSingle, so it lands in the SINGLE scenario of ci-azure.yml, where the instance is local to the runner VM.
The other lanes are not affected and cannot be:
integration-tests.yml (Linux) runs only .github/scripts/gh-actions.ps1, which does not touch Set-DbaLogin, and its instances are SQL Server on Linux in containers, which has no Windows account policy at all.
- The
windows-tests job in the same workflow is disabled (if: false, since 2026-07-14).
The change
One line in the image build, for example in .github/runners/image-scripts/step2-sqlinstance.ps1:
# A SQL login can only be locked out when the host has an account lockout threshold.
$null = net accounts /lockoutthreshold:5 /lockoutduration:10 /lockoutwindow:10
Only the threshold is strictly required, and it has to be 5 or lower, because the test fails exactly five logons. Duration and observation window only decide when Windows would release the lock by itself; the test unlocks explicitly, so they are there for determinism.
Two things worth checking
- Sysprep.
step3-sysprep.ps1 runs sysprep /generalize. Please confirm the local security policy survives generalize on the resulting image - if it does not, the setting belongs in a boot-time step such as bootstrap-runner.ps1 instead.
- Scope. The image is shared by every runner pool, so this changes the account lockout policy for all CI runners, not just one pool. It affects local SAM accounts on the runner; the practical effect is that SQL logins with
CHECK_POLICY = ON can now lock after five bad passwords.
How to verify on a runner
SQL Server reads the local policy of the machine running the instance, not the domain policy. That was confirmed in a lab where the domain policy has no threshold at all and the local one has five - the lockout follows the local value.
$login = "dbatoolsci_lockout_test"
$null = New-DbaLogin -SqlInstance $instance -Login $login -Password (ConvertTo-SecureString "password1A@" -AsPlainText -Force)
$null = Set-DbaLogin -SqlInstance $instance -Login $login -PasswordPolicyEnforced -EnableException
$badCred = New-Object System.Management.Automation.PSCredential ($login, (ConvertTo-SecureString "wrong" -AsPlainText -Force))
foreach ($attempt in 1..5) {
# NonPooledConnection is essential: after a failed logon SqlClient blocks the pool for a growing
# number of seconds and answers the next attempts itself, so they never reach the instance.
try { $null = Connect-DbaInstance -SqlInstance $instance -SqlCredential $badCred -NonPooledConnection } catch { }
}
Invoke-DbaQuery -SqlInstance $instance -Query "SELECT LOGINPROPERTY('$login','BadPasswordCount') AS Bad, LOGINPROPERTY('$login','IsLocked') AS Locked"
$null = Remove-DbaLogin -SqlInstance $instance -Login $login -Force
With a threshold of five this returns Bad = 5 and Locked = 1. Today on a runner it returns Locked = 0.
🤖 Generated with Claude Code
What is missing
The self-hosted runner image sets no Windows account lockout threshold, so a SQL login can never be locked out on a CI runner. Nothing under
.github/runners/configures one - there is nonet accounts, noseceditand noLockoutBadCountanywhere in the image build.Without a threshold,
LOGINPROPERTY(login, 'IsLocked')stays0no matter how many logons fail, so no test can cover unlocking a login.Why this matters now
tests/Set-DbaLogin.Tests.ps1contains a test that locks a login out and unlocks it three different ways. It has been skipped for years behindIt -Skip "Unlock"with a note saying it "makes assumptions the password policy configuration is enabled for the Windows OS". Enabling it found a real bug inSet-DbaLogin, which is fixed in a separate PR - but that PR has to leave the test enabled and failing on CI until the runners have a threshold, because a skipped test would hide the regression again.Which lane needs it
Only the self-hosted Azure lane.
Set-DbaLogin.Tests.ps1uses$TestConfig.InstanceSingle, so it lands in theSINGLEscenario ofci-azure.yml, where the instance is local to the runner VM.The other lanes are not affected and cannot be:
integration-tests.yml(Linux) runs only.github/scripts/gh-actions.ps1, which does not touchSet-DbaLogin, and its instances are SQL Server on Linux in containers, which has no Windows account policy at all.windows-testsjob in the same workflow is disabled (if: false, since 2026-07-14).The change
One line in the image build, for example in
.github/runners/image-scripts/step2-sqlinstance.ps1:Only the threshold is strictly required, and it has to be 5 or lower, because the test fails exactly five logons. Duration and observation window only decide when Windows would release the lock by itself; the test unlocks explicitly, so they are there for determinism.
Two things worth checking
step3-sysprep.ps1runssysprep /generalize. Please confirm the local security policy survives generalize on the resulting image - if it does not, the setting belongs in a boot-time step such asbootstrap-runner.ps1instead.CHECK_POLICY = ONcan now lock after five bad passwords.How to verify on a runner
SQL Server reads the local policy of the machine running the instance, not the domain policy. That was confirmed in a lab where the domain policy has no threshold at all and the local one has five - the lockout follows the local value.
With a threshold of five this returns
Bad = 5andLocked = 1. Today on a runner it returnsLocked = 0.🤖 Generated with Claude Code