diff --git a/packaging/chocolatey/README.md b/packaging/chocolatey/README.md new file mode 100644 index 0000000..10b4acc --- /dev/null +++ b/packaging/chocolatey/README.md @@ -0,0 +1,59 @@ +# DevNav Chocolatey packaging source + +This directory contains the source for a future Chocolatey package. It is not +published and is deliberately not tied to v0.14.0: the package must be +materialized from the release manifest of a future release that includes this +integration. + +The package is machine-owned and installs the official, versioned release +assets into: + +```text +%ChocolateyInstall%\lib\devnav\tools\DevNav\ + dev.exe + DevNav.psm1 + DevNav.psd1 + .devnav-managed-by-chocolatey +``` + +It registers `%ChocolateyInstall%\lib\devnav\tools` in the machine +`PSModulePath`, without changing any PowerShell profile. The module keeps each +user's configuration in that user's `%LOCALAPPDATA%\DevNav` directory. + +## Materialize and pack + +The release workflow's `release-manifest.json` is the input source of truth. +The materializer constructs the immutable GitHub Release URLs and injects the +published SHA-256 values; it rejects other URLs, missing architectures and +invalid hashes. + +```powershell +./scripts/New-DevNavChocolateyPackage.ps1 ` + -Version 0.15.0 ` + -ReleaseManifest .\release-manifest.json ` + -OutputDirectory $env:TEMP\devnav-chocolatey +choco pack $env:TEMP\devnav-chocolatey\devnav.nuspec +``` + +Do not add compiled binaries to this repository and do not run `choco push` +from this project without a separate release decision. + +## Architecture and ownership + +Native architecture is detected from `PROCESSOR_ARCHITECTURE` and +`PROCESSOR_ARCHITEW6432`, not Chocolatey's processor-width helpers. Native +x64 and ARM64 select their matching official binary. Real x86 is rejected, +and `--forcex86` cannot select an incorrect asset. + +Installation, upgrade and uninstall are expected to be idempotent. The +Chocolatey marker prevents DevNav self-updates; `dev update` only tells the +user to run `choco upgrade devnav`. The package never runs DevNav as +administrator to initialize user configuration. + +## Community verifier limitation + +DevNav supports Windows 10/11 on x64 and ARM64. It does not support x86 and +does not declare Windows Server 2019. The Chocolatey Community verifier runs +on Windows Server 2019 and also exercises `forcex86`. Publishing will +therefore require a verifier exemption request; this source does not broaden +DevNav's supported platforms and does not request the exemption yet. diff --git a/packaging/chocolatey/devnav.nuspec b/packaging/chocolatey/devnav.nuspec new file mode 100644 index 0000000..9534cbc --- /dev/null +++ b/packaging/chocolatey/devnav.nuspec @@ -0,0 +1,16 @@ + + + + devnav + __VERSION__ + DevNav + JacobOptimiza + JacobOptimiza + https://github.com/JacobOptimiza/dev-nav/blob/main/LICENSE + https://github.com/JacobOptimiza/dev-nav + false + Native Rust workspace navigator and coding-agent launcher for PowerShell 7 on Windows. + Keyboard-first workspace navigation for Windows. + devnav developer powershell tui workspace navigator portable + + diff --git a/packaging/chocolatey/tools/DevNavChocolatey.ps1 b/packaging/chocolatey/tools/DevNavChocolatey.ps1 new file mode 100644 index 0000000..eed6b13 --- /dev/null +++ b/packaging/chocolatey/tools/DevNavChocolatey.ps1 @@ -0,0 +1,80 @@ +Set-StrictMode -Version Latest + +function Get-DevNavNativeArchitecture { + [CmdletBinding()] + param( + [string] $ProcessorArchitecture = $env:PROCESSOR_ARCHITECTURE, + [string] $ProcessorArchW6432 = $env:PROCESSOR_ARCHITEW6432, + [bool] $ForceX86 = ($env:ChocolateyForceX86 -eq 'true') + ) + + $process = ([string]$ProcessorArchitecture).Trim().ToUpperInvariant() + $wow = ([string]$ProcessorArchW6432).Trim().ToUpperInvariant() + $native = if ($process -eq 'ARM64' -or $wow -eq 'ARM64') { + 'arm64' + } + elseif ($process -eq 'AMD64' -or $wow -eq 'AMD64') { + 'x64' + } + elseif ($process -eq 'X86' -and [string]::IsNullOrWhiteSpace($wow)) { + 'x86' + } + else { + throw "Unsupported Windows architecture environment: PROCESSOR_ARCHITECTURE='$process', PROCESSOR_ARCHITEW6432='$wow'." + } + + if ($ForceX86 -and $native -ne 'x86') { + throw "Chocolatey --forcex86 is unsafe for DevNav: the native OS architecture is $native. DevNav does not provide x86 assets." + } + if ($native -eq 'x86') { + throw 'DevNav does not support x86 Windows. Use a native x64 or ARM64 system.' + } + return $native +} + +function Get-DevNavAssetSet { + param( + [Parameter(Mandatory)][ValidateSet('x64', 'arm64')][string] $Architecture, + [Parameter(Mandatory)][hashtable] $Assets + ) + + $selected = $Assets[$Architecture] + if ($null -eq $selected) { throw "No DevNav release assets were supplied for $Architecture." } + foreach ($name in @('dev', 'module', 'manifest')) { + if ([string]::IsNullOrWhiteSpace([string]$selected[$name].url) -or + [string]::IsNullOrWhiteSpace([string]$selected[$name].sha256)) { + throw "The $Architecture DevNav asset '$name' is missing its URL or SHA-256." + } + } + return $selected +} + +function Assert-DevNavSha256 { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string] $Expected + ) + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + if ($actual -ne $Expected) { throw "SHA-256 mismatch for '$Path'." } +} + +function Add-DevNavMachineModulePath { + param([Parameter(Mandatory)][string] $PackageTools) + + $machine = [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') + $entries = @($machine -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($entries -notcontains $PackageTools) { + [Environment]::SetEnvironmentVariable('PSModulePath', (($entries + $PackageTools) -join ';'), 'Machine') + } +} + +function Remove-DevNavMachineModulePath { + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)][string] $PackageTools) + + $machine = [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') + $entries = @($machine -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne $PackageTools }) + if ($PSCmdlet.ShouldProcess('Machine PSModulePath', "Remove '$PackageTools'")) { + [Environment]::SetEnvironmentVariable('PSModulePath', ($entries -join ';'), 'Machine') + } +} diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1 new file mode 100644 index 0000000..c67bd5a --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyInstall.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DevNavChocolatey.ps1') + +$architecture = Get-DevNavNativeArchitecture +$assets = @{ + x64 = @{ + dev = @{ url = '__X64_DEV_URL__'; sha256 = '__X64_DEV_SHA256__' } + module = @{ url = '__MODULE_URL__'; sha256 = '__MODULE_SHA256__' } + manifest = @{ url = '__MANIFEST_URL__'; sha256 = '__MANIFEST_SHA256__' } + } + arm64 = @{ + dev = @{ url = '__ARM64_DEV_URL__'; sha256 = '__ARM64_DEV_SHA256__' } + module = @{ url = '__MODULE_URL__'; sha256 = '__MODULE_SHA256__' } + manifest = @{ url = '__MANIFEST_URL__'; sha256 = '__MANIFEST_SHA256__' } + } +} +$selected = Get-DevNavAssetSet -Architecture $architecture -Assets $assets +$packageTools = Join-Path $env:ChocolateyPackageFolder 'tools' +$moduleRoot = Join-Path $packageTools 'DevNav' +New-Item -ItemType Directory -Path $moduleRoot -Force | Out-Null + +Get-ChocolateyWebFile -PackageName 'devnav' -FileFullPath (Join-Path $moduleRoot 'dev.exe') -Url $selected.dev.url -Checksum $selected.dev.sha256 -ChecksumType 'sha256' +Get-ChocolateyWebFile -PackageName 'devnav' -FileFullPath (Join-Path $moduleRoot 'DevNav.psm1') -Url $selected.module.url -Checksum $selected.module.sha256 -ChecksumType 'sha256' +Get-ChocolateyWebFile -PackageName 'devnav' -FileFullPath (Join-Path $moduleRoot 'DevNav.psd1') -Url $selected.manifest.url -Checksum $selected.manifest.sha256 -ChecksumType 'sha256' +Assert-DevNavSha256 -Path (Join-Path $moduleRoot 'dev.exe') -Expected $selected.dev.sha256 +Assert-DevNavSha256 -Path (Join-Path $moduleRoot 'DevNav.psm1') -Expected $selected.module.sha256 +Assert-DevNavSha256 -Path (Join-Path $moduleRoot 'DevNav.psd1') -Expected $selected.manifest.sha256 +New-Item -ItemType File -Path (Join-Path $moduleRoot '.devnav-managed-by-chocolatey') -Force | Out-Null +Add-DevNavMachineModulePath -PackageTools $packageTools diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1 new file mode 100644 index 0000000..62cbe67 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DevNavChocolatey.ps1') +Remove-DevNavMachineModulePath -PackageTools (Join-Path $env:ChocolateyPackageFolder 'tools') diff --git a/scripts/New-DevNavChocolateyPackage.ps1 b/scripts/New-DevNavChocolateyPackage.ps1 new file mode 100644 index 0000000..022fe26 --- /dev/null +++ b/scripts/New-DevNavChocolateyPackage.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$')][string] $Version, + [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })][string] $ReleaseManifest, + [Parameter(Mandatory)][string] $OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$templateRoot = Join-Path $PSScriptRoot '..\packaging\chocolatey' +$manifest = Get-Content -LiteralPath $ReleaseManifest -Raw | ConvertFrom-Json +$baseUrl = "https://github.com/JacobOptimiza/dev-nav/releases/download/v$Version" +$artifacts = $manifest.artifacts +$assets = @{} +foreach ($architecture in @('x64', 'arm64')) { + $suffix = if ($architecture -eq 'x64') { 'x86_64' } else { 'aarch64' } + $binary = $artifacts."binary-$architecture" + if ($null -eq $binary) { throw "Release manifest has no binary-$architecture artifact." } + $assets[$architecture] = @{ + dev = @{ url = "$baseUrl/dev-windows-$suffix.exe"; sha256 = [string]$binary.sha256 } + module = @{ url = "$baseUrl/DevNav.psm1"; sha256 = [string]$artifacts.module.sha256 } + manifest = @{ url = "$baseUrl/DevNav.psd1"; sha256 = [string]$artifacts.'module-manifest'.sha256 } + } +} + +function Assert-ReleaseUrl([string]$url, [string]$expectedAsset) { + $prefix = "https://github.com/JacobOptimiza/dev-nav/releases/download/v$Version/" + if ($url -ne "$prefix$expectedAsset") { throw "Unexpected release URL for ${expectedAsset}: $url" } +} +Assert-ReleaseUrl $assets.x64.dev.url 'dev-windows-x86_64.exe' +Assert-ReleaseUrl $assets.arm64.dev.url 'dev-windows-aarch64.exe' +Assert-ReleaseUrl $assets.x64.module.url 'DevNav.psm1' +Assert-ReleaseUrl $assets.x64.manifest.url 'DevNav.psd1' +foreach ($architecture in @('x64', 'arm64')) { + foreach ($name in @('dev', 'module', 'manifest')) { + if ($assets[$architecture][$name].sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw "Invalid SHA-256 for $architecture/$name." } + } +} + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $templateRoot 'devnav.nuspec') -Destination $OutputDirectory -Force +Copy-Item -LiteralPath (Join-Path $templateRoot 'tools') -Destination $OutputDirectory -Recurse -Force +$nuspec = Join-Path $OutputDirectory 'devnav.nuspec' +(Get-Content -LiteralPath $nuspec -Raw).Replace('__VERSION__', $Version) | Set-Content -LiteralPath $nuspec -Encoding utf8NoBOM +$install = Join-Path $OutputDirectory 'tools\chocolateyInstall.ps1' +$text = Get-Content -LiteralPath $install -Raw +$replacements = @{ + '__X64_DEV_URL__' = $assets.x64.dev.url; '__X64_DEV_SHA256__' = $assets.x64.dev.sha256 + '__ARM64_DEV_URL__' = $assets.arm64.dev.url; '__ARM64_DEV_SHA256__' = $assets.arm64.dev.sha256 + '__MODULE_URL__' = $assets.x64.module.url; '__MODULE_SHA256__' = $assets.x64.module.sha256 + '__MANIFEST_URL__' = $assets.x64.manifest.url; '__MANIFEST_SHA256__' = $assets.x64.manifest.sha256 +} +foreach ($replacement in $replacements.GetEnumerator()) { $text = $text.Replace($replacement.Key, $replacement.Value) } +$text | Set-Content -LiteralPath $install -Encoding utf8NoBOM +Write-Output $OutputDirectory diff --git a/tests/powershell/ChocolateyPackaging.Tests.ps1 b/tests/powershell/ChocolateyPackaging.Tests.ps1 new file mode 100644 index 0000000..ab3d5b4 --- /dev/null +++ b/tests/powershell/ChocolateyPackaging.Tests.ps1 @@ -0,0 +1,75 @@ +BeforeAll { + $script:repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:helperPath = Join-Path $repositoryRoot 'packaging\chocolatey\tools\DevNavChocolatey.ps1' + $script:installPath = Join-Path $repositoryRoot 'packaging\chocolatey\tools\chocolateyInstall.ps1' + $script:materializerPath = Join-Path $repositoryRoot 'scripts\New-DevNavChocolateyPackage.ps1' + . $script:helperPath +} + +Describe 'Chocolatey native architecture routing' { + It 'routes native x64 to x64' { + Get-DevNavNativeArchitecture -ProcessorArchitecture AMD64 -ProcessorArchW6432 '' | Should -Be 'x64' + } + It 'routes an x86 process on x64 to x64' { + Get-DevNavNativeArchitecture -ProcessorArchitecture x86 -ProcessorArchW6432 AMD64 | Should -Be 'x64' + } + It 'routes native ARM64 to arm64' { + Get-DevNavNativeArchitecture -ProcessorArchitecture ARM64 -ProcessorArchW6432 '' | Should -Be 'arm64' + } + It 'routes an x86 process on ARM64 to arm64' { + Get-DevNavNativeArchitecture -ProcessorArchitecture x86 -ProcessorArchW6432 ARM64 | Should -Be 'arm64' + } + It 'rejects real x86' { + { Get-DevNavNativeArchitecture -ProcessorArchitecture x86 -ProcessorArchW6432 '' } | Should -Throw '*does not support x86*' + } + It 'rejects forcex86 on a native supported OS' { + { Get-DevNavNativeArchitecture -ProcessorArchitecture AMD64 -ProcessorArchW6432 '' -ForceX86 $true } | Should -Throw '*forcex86*' + } +} + +Describe 'Chocolatey package source contract' { + It 'uses official direct assets and the machine-owned marker' { + $source = (Get-Content -LiteralPath $script:installPath -Raw) + (Get-Content -LiteralPath $script:helperPath -Raw) + $source | Should -Match 'Get-ChocolateyWebFile' + $source | Should -Match '\.devnav-managed-by-chocolatey' + $source | Should -Not -Match 'DevNavSetup|Get-OSArchitectureWidth|Get-ProcessorBits' + $source | Should -Match 'PSModulePath' + } + + It 'materializes versioned URLs and hashes from a release manifest' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('devnav-choco-materialize-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $root | Out-Null + try { + $manifest = [ordered]@{ + schemaVersion = 1 + version = '9.8.7' + artifacts = [ordered]@{ + 'binary-x64' = @{ file = 'dev-windows-x86_64.exe'; sha256 = ('a' * 64) } + 'binary-arm64' = @{ file = 'dev-windows-aarch64.exe'; sha256 = ('b' * 64) } + module = @{ file = 'DevNav.psm1'; sha256 = ('c' * 64) } + 'module-manifest' = @{ file = 'DevNav.psd1'; sha256 = ('d' * 64) } + } + } + $manifestPath = Join-Path $root 'release-manifest.json' + $manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + $output = Join-Path $root 'package' + & $script:materializerPath -Version '9.8.7' -ReleaseManifest $manifestPath -OutputDirectory $output | Out-Null + $nuspec = Get-Content (Join-Path $output 'devnav.nuspec') -Raw + $install = Get-Content (Join-Path $output 'tools\chocolateyInstall.ps1') -Raw + $nuspec | Should -Match '9\.8\.7' + $install | Should -Match 'releases/download/v9\.8\.7/dev-windows-x86_64\.exe' + $install | Should -Match ('a' * 64) + $install | Should -Not -Match '__[A-Z0-9_]+__' + } + finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'rejects a tampered file checksum' { + $path = Join-Path ([IO.Path]::GetTempPath()) ('devnav-choco-tamper-' + [guid]::NewGuid().ToString('N')) + 'original' | Set-Content -LiteralPath $path -NoNewline + try { + { Assert-DevNavSha256 -Path $path -Expected ('0' * 64) } | Should -Throw '*SHA-256 mismatch*' + } + finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } +}