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
7 changes: 7 additions & 0 deletions cicd/config.bash
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ LINT_EXTRA=(
'pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path source/powershell/shcl.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit"'
'pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path install.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit"'
'pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path cicd/utility/n8runshcl.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit"'
'pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path cicd/packaging/shclpath.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit"'
'pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path cicd/utility/winpath-regress.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit"'
'cicd/utility/check-completions.bash'
'cicd/utility/check-wheel.bash'
## The README's C example is the first thing a C consumer copies, and it is
Expand All @@ -136,6 +138,7 @@ SHELLCHECK_TARGETS=(
cicd/utility/check-c-compilers.bash
cicd/utility/check-completions.bash
cicd/utility/check-docs.bash
cicd/utility/check-install-dev.bash
cicd/utility/check-locale.bash
cicd/utility/check-pins.bash
cicd/utility/check-readme-c.bash
Expand Down Expand Up @@ -194,6 +197,10 @@ TEST_EXTRA=(
'cicd/utility/perf-gate.bash "${BINDING_CLIS[@]}"'
## The wrappers, the one-liner's scope hygiene, and the errexit grep trap.
'cicd/utility/shell-regress.bash'
## install-dev's hook setup, through its --hooks-only path on a throwaway
## clone - the one piece of that script the toolchain installs used to wall
## off from any gate.
'cicd/utility/check-install-dev.bash'
## Every C compiler on the box, not just the default one: the hosted runner's
## gcc is a different version, and the two disagree about what -Werror
## rejects. A round went out green here and red there over exactly that.
Expand Down
18 changes: 3 additions & 15 deletions cicd/packaging/shcl.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,10 @@ VIAddVersionKey "LegalCopyright" "Copyright (C) 2026 Jim Collier. MIT License."
; through NSIS variables: NSIS strings are capped at NSIS_MAX_STRLEN (1024), so
; a longer PATH came back truncated - or empty, which no length guard can tell
; from a genuinely empty value - and writing that back destroyed it. The script
; also compares whole segments case-insensitively (a substring test let any
; directory containing the name suppress the append) and keeps REG_EXPAND_SZ.
; is a real file (shclpath.ps1, beside this one) rather than FileWrite lines,
; so the text that ships is the text the hosted gate runs.
!macro WriteShclPathPs1
FileOpen $0 "$PLUGINSDIR\shclpath.ps1" w
FileWrite $0 "param([string]$$Dir, [switch]$$Remove)$\r$\n"
FileWrite $0 "$$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $$true)$\r$\n"
FileWrite $0 "$$cur = [string]$$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)$\r$\n"
FileWrite $0 "$$parts = @($$cur -split ';' | Where-Object { $$_ -ne '' })$\r$\n"
FileWrite $0 "if ($$Remove) {$\r$\n"
FileWrite $0 " $$new = @($$parts | Where-Object { $$_ -ne $$Dir }) -join ';'$\r$\n"
FileWrite $0 " if ($$new -ne $$cur) { $$key.SetValue('Path', $$new, [Microsoft.Win32.RegistryValueKind]::ExpandString) }$\r$\n"
FileWrite $0 "} elseif ($$parts -notcontains $$Dir) {$\r$\n"
FileWrite $0 " $$key.SetValue('Path', (@($$parts + $$Dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)$\r$\n"
FileWrite $0 "}$\r$\n"
FileWrite $0 "$$key.Close()$\r$\n"
FileClose $0
File "/oname=$PLUGINSDIR\shclpath.ps1" "shclpath.ps1"
!macroend

Section "Install"
Expand Down
21 changes: 21 additions & 0 deletions cicd/packaging/shclpath.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## shclpath.ps1 - the setup's machine-PATH edit, packed into the installer and
## run at install and uninstall. It goes straight at the registry, never through
## NSIS variables: NSIS strings are capped at NSIS_MAX_STRLEN (1024), so a
## longer PATH came back truncated - or empty, which no length guard can tell
## from a genuinely empty value - and writing that back destroyed it. Segments
## are compared whole (a substring test let any directory containing the name
## suppress the append), the value is read unexpanded so %VAR% references
## survive, and it is written back REG_EXPAND_SZ so the type does not downgrade.
##
## Copyright (c) 2026 Jim Collier. MIT License.
param([string]$Dir, [switch]$Remove)
$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true)
$cur = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$parts = @($cur -split ';' | Where-Object { $_ -ne '' })
if ($Remove) {
$new = @($parts | Where-Object { $_ -ne $Dir }) -join ';'
if ($new -ne $cur) { $key.SetValue('Path', $new, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
} elseif ($parts -notcontains $Dir) {
$key.SetValue('Path', (@($parts + $Dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
$key.Close()
66 changes: 66 additions & 0 deletions cicd/utility/check-install-dev.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env bash

## Purpose:
## Run install-dev.bash --hooks-only against a throwaway clone and check
## what it leaves behind. The hook setup was the one piece of that script
## nothing exercised: the toolchain installs in front of it cannot run in
## a gate, so a regression there would only be found by the next person
## setting up a box. --hooks-only skips the installs, which makes the tail
## runnable here - against the shipped script, not a copy of its logic.
## Syntax:
## check-install-dev.bash
## Exit: 0 = all checks pass, 1 = a check failed (named), 2 = cannot set up.
## History: At bottom of script.

## Copyright © 2026 Bubbles (ID: XଌฅრX۳ᛟԃლፀƅꓩหδლც)
## Licensed under The MIT License (MIT). Full text at:
## https://mit-license.org/
## SPDX-License-Identifier: MIT


set -Eeuo pipefail

root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
script="${root}/install-dev.bash"
[[ -r "${script}" ]] || { echo "check-install-dev: cannot read ${script}" >&2; exit 2; }

work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT
## --no-hardlinks: /tmp is routinely a different filesystem from the repo.
git clone -q --no-hardlinks --local "${root}" "${work}/clone" || { echo "check-install-dev: local clone failed" >&2; exit 2; }

rc=0
fail() { echo "check-install-dev: FAIL: $*" >&2; rc=1; }

## From a neutral cwd, pointed at the clone by --dir: sets both configs.
( cd "${work}" && bash "${script}" --hooks-only --dir clone >/dev/null )
[[ "$(git -C "${work}/clone" config core.hooksPath)" == "cicd/hooks" ]] || fail "hooksPath not set"
[[ "$(git -C "${work}/clone" config core.sshCommand)" == *ServerAliveInterval* ]] || fail "ssh keepalive not set"

## Idempotent: a second run changes nothing and still exits 0.
before="$(git -C "${work}/clone" config --list --local)"
( cd "${work}" && bash "${script}" --hooks-only --dir clone >/dev/null ) || fail "second run failed"
[[ "$(git -C "${work}/clone" config --list --local)" == "${before}" ]] || fail "second run changed the config"

## A chosen sshCommand survives: the keepalive is only for the unconfigured.
git -C "${work}/clone" config core.sshCommand "ssh -i /keep/this"
( cd "${work}/clone" && bash "${script}" --hooks-only >/dev/null )
[[ "$(git -C "${work}/clone" config core.sshCommand)" == "ssh -i /keep/this" ]] || fail "a configured sshCommand was overwritten"

## Run inside the clone with no --dir: the in-clone detection finds it.
git -C "${work}/clone" config --unset core.hooksPath
( cd "${work}/clone" && bash "${script}" --hooks-only >/dev/null )
[[ "$(git -C "${work}/clone" config core.hooksPath)" == "cicd/hooks" ]] || fail "in-clone run did not set hooksPath"

## Not a clone: refused, nothing created.
mkdir "${work}/empty"
if ( cd "${work}" && bash "${script}" --hooks-only --dir empty >/dev/null 2>&1 ); then
fail "--hooks-only accepted a directory that is not a clone"
fi

(( rc == 0 )) && echo "check-install-dev: OK: --hooks-only sets the hooks path and keepalive, idempotently, and refuses a non-clone"
exit "${rc}"


## History:
## - 2026-09-01 JC: Created.
2 changes: 2 additions & 0 deletions cicd/utility/package.bash
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ if command -v makensis >/dev/null 2>&1; then
## Same icon the executables carry. Absent is not an error - the setup just
## falls back to the NSIS default.
icoArg=""; [[ -f "${root}/assets/shcl.ico" ]] && icoArg="${root}/assets/shcl.ico"
## Packed verbatim into the setup; its mtime rides into the archive too.
fPinMtime "${meDir}/../packaging/shclpath.ps1"
for osarch in x86_64 arm64; do
exe="${artDir}/shcl-${ver}-windows-${osarch}.exe"
[[ -f "${exe}" ]] || continue
Expand Down
14 changes: 12 additions & 2 deletions cicd/utility/win-runners.bash
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
## the library with real per-platform code - covered only where the
## platform-specific half never executes, and the python binding threw on
## every windows overwrite for a release without anything noticing. This is
## the smaller gate that does run there: the runners and the veneer smoke,
## and nothing else.
## the smaller gate that does run there: the runners, the veneer smoke and,
## on a real windows host, the installers' registry PATH handling.
## Syntax:
## win-runners.bash [ROOT]
## ROOT repo root (default: two levels up from this script)
Expand Down Expand Up @@ -88,6 +88,14 @@ fRun "python" "${py}" source/python/tests/conformance.py
fRun "c" fRunC
fRun "c++ veneer" fRunCxx
fRun "c oom hook" fRunOom
## The installers' PATH handling needs a real registry, which only exists here:
## it edits and restores the runner's own Environment keys, so it stays off
## every other host.
case "$(uname -s 2>/dev/null || true)" in
MINGW*|MSYS*|CYGWIN*)
fRun "windows path" powershell -NoProfile -ExecutionPolicy Bypass -File cicd/utility/winpath-regress.ps1
;;
esac

echo
if ((${#failed[@]} == 0)); then
Expand All @@ -101,3 +109,5 @@ fi
## Script history:
## - 20260821: Created. Nothing in the pipeline ran any binding on windows,
## where the file tier's publish step is a different code path in all four.
## - 20260901: The installers' PATH handling joins, windows hosts only - it
## needs a real registry.
110 changes: 110 additions & 0 deletions cicd/utility/winpath-regress.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env pwsh
## winpath-regress.ps1 - the Windows installers' PATH handling, against the real
## registry, so it only runs on the hosted windows job (win-runners.bash guards
## that). Two subjects, both the shipped text rather than a copy of the logic:
## install.ps1's Update-ShclPath, lifted from the script by name and run against
## HKCU, and the setup's shclpath.ps1 run as the installer runs it, against
## HKLM. What they must preserve: %VAR% references (read unexpanded), the
## REG_EXPAND_SZ kind, and whole-segment comparison. Everything touched is
## saved first and restored in a finally.
##
## Exit: 0 = all checks pass, 1 = a check failed (named), 2 = cannot set up.

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'runs the function text lifted from the shipped installer')]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if (-not ($env:OS -eq 'Windows_NT')) {
Write-Output 'winpath-regress: not windows; nothing to test here'
exit 2
}

$root = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$failures = 0
function Test-Check([bool]$Ok, [string]$Name) {
if ($Ok) { Write-Output "winpath-regress: OK: $Name" }
else { Write-Output "winpath-regress: FAIL: $Name"; $script:failures++ }
}

## Lift Update-ShclPath out of install.ps1 by name - the shipped text, so a
## drift in the script is a drift in the test subject.
$lines = Get-Content (Join-Path $root 'install.ps1')
$start = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^\tfunction Update-ShclPath\b') { $start = $i; break }
}
if ($start -lt 0) { Write-Output 'winpath-regress: install.ps1 has no Update-ShclPath'; exit 2 }
$end = -1
for ($i = $start + 1; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '^\t\}\s*$') { $end = $i; break }
}
if ($end -lt 0) { Write-Output 'winpath-regress: Update-ShclPath never closes'; exit 2 }
Invoke-Expression (($lines[$start..$end] -join "`n"))

function Get-RawPath([Microsoft.Win32.RegistryKey]$Key) {
[string]$Key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
}
function Get-PathKind([Microsoft.Win32.RegistryKey]$Key) {
if ($Key.GetValueNames() -contains 'Path') { $Key.GetValueKind('Path') } else { $null }
}
function Restore-PathValue([Microsoft.Win32.RegistryKey]$Key, [string]$Value, $Kind) {
if ($null -eq $Kind) { if ($Key.GetValueNames() -contains 'Path') { $Key.DeleteValue('Path') } }
else { $Key.SetValue('Path', $Value, $Kind) }
}

## HKCU: seed a user PATH the way real ones look - a %USERPROFILE% reference,
## stored REG_EXPAND_SZ - and check every property on the shipped function.
$cu = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$savedCu = Get-RawPath $cu
$savedCuKind = Get-PathKind $cu
try {
$seed = '%USERPROFILE%\bin;C:\seeded'
$cu.SetValue('Path', $seed, [Microsoft.Win32.RegistryValueKind]::ExpandString)
$dir = 'C:\shcl-pathtest'
Test-Check (Update-ShclPath -Scope User -Dir $dir) 'user add reports a write'
Test-Check ((Get-RawPath $cu) -eq "$seed;$dir") 'user add appends, references unexpanded'
Test-Check ((Get-PathKind $cu) -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) 'user add keeps REG_EXPAND_SZ'
Test-Check (-not (Update-ShclPath -Scope User -Dir $dir)) 'user add is idempotent'
Test-Check ((Get-RawPath $cu) -eq "$seed;$dir") 'idempotent add leaves the value alone'
## Whole segments: a dir containing the other's name is not "already there".
Test-Check (Update-ShclPath -Scope User -Dir "${dir}2") 'a superstring dir still appends'
Test-Check ((Get-RawPath $cu) -eq "$seed;$dir;${dir}2") 'both segments present'
Test-Check (Update-ShclPath -Scope User -Dir $dir -Remove) 'user remove reports a write'
Test-Check ((Get-RawPath $cu) -eq "$seed;${dir}2") 'remove takes its segment alone'
Test-Check ((Get-PathKind $cu) -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) 'remove keeps REG_EXPAND_SZ'
Test-Check (-not (Update-ShclPath -Scope User -Dir 'C:\never-there' -Remove)) 'removing an absent segment writes nothing'
} finally {
Restore-PathValue $cu $savedCu $savedCuKind
$cu.Close()
}

## HKLM: the setup's shclpath.ps1, run the way the installer runs it. No
## seeding - the machine PATH is the runner's working one - so the assertions
## are segment-wise against what was there.
$lm = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true)
$savedLm = Get-RawPath $lm
$savedLmKind = Get-PathKind $lm
$script = Join-Path $root 'cicd\packaging\shclpath.ps1'
$dir = 'C:\shcl-nsistest'
try {
$before = @($savedLm -split ';' | Where-Object { $_ -ne '' })
& powershell -NoProfile -ExecutionPolicy Bypass -File $script -Dir $dir
Test-Check ($LASTEXITCODE -eq 0) 'setup add exits 0'
$after = @((Get-RawPath $lm) -split ';' | Where-Object { $_ -ne '' })
Test-Check (($after -join ';') -eq (($before + $dir) -join ';')) 'setup add appends one segment, the rest byte-identical'
Test-Check ((Get-PathKind $lm) -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) 'setup add keeps REG_EXPAND_SZ'
& powershell -NoProfile -ExecutionPolicy Bypass -File $script -Dir $dir
Test-Check ((Get-RawPath $lm) -eq ($after -join ';')) 'setup add is idempotent'
& powershell -NoProfile -ExecutionPolicy Bypass -File $script -Dir $dir -Remove
Test-Check ($LASTEXITCODE -eq 0) 'setup remove exits 0'
Test-Check ((Get-RawPath $lm) -eq ($before -join ';')) 'setup remove restores the segments'
} finally {
Restore-PathValue $lm $savedLm $savedLmKind
$lm.Close()
}

if ($failures -eq 0) { Write-Output 'winpath-regress: OK'; exit 0 }
Write-Output "winpath-regress: $failures check(s) failed"
exit 1
Loading