Files
nana-story/scripts/windows-smoke.ps1
T
Codex 23672e857b
verify / verify (push) Has been cancelled
feat(app): harden cancellable LAPP turns
2026-07-28 08:44:53 -04:00

481 lines
18 KiB
PowerShell

<#
.SYNOPSIS
Runs the Windows developer smoke gates for nana-story.
.DESCRIPTION
Checks the pinned developer toolchain and adjacent lapp-rs checkout, optionally
installs JavaScript dependencies, then runs the repository verification and a
non-bundled Tauri production build. With -Launch, it starts `pnpm tauri dev`
after all gates pass.
This script deliberately has no credential parameters. Git authentication and
LAPP profile/Vault setup must be completed outside this process.
.PARAMETER InstallDependencies
Runs `pnpm install --frozen-lockfile`. Without this switch, the script never
installs dependencies and requires an existing pnpm node_modules layout.
.PARAMETER Launch
Starts the desktop application with `pnpm tauri dev` after verification.
.PARAMETER Demo
Uses the deterministic demo provider for the optional desktop launch. This
switch is only valid together with -Launch.
.PARAMETER SmokeDataPath
Absolute child path below the Windows temporary directory used as isolated app
data for -Launch. Reuse the same path to test restart recovery; choose a new
path to start from a fresh database. The script never deletes this directory.
#>
[CmdletBinding(PositionalBinding = $false)]
param(
[switch]$InstallDependencies,
[switch]$Launch,
[switch]$Demo,
[string]$SmokeDataPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Find-NativeCommand {
param(
[Parameter(Mandatory = $true)]
[string[]]$Names,
[Parameter(Mandatory = $true)]
[string]$DisplayName
)
foreach ($name in $Names) {
$command = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -ne $command) {
return $command.Source
}
}
throw "$DisplayName was not found on PATH. Install it outside this script and retry."
}
function Invoke-NativeCapture {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$Label
)
$output = @(& $FilePath @ArgumentList 2>&1)
$exitCode = $LASTEXITCODE
$text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
if ($exitCode -ne 0) {
throw "$Label failed with exit code $exitCode. $text"
}
return $text.Trim()
}
function Invoke-NativeChecked {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$Label
)
Write-Host ("[run] {0}" -f $Label)
& $FilePath @ArgumentList
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "$Label failed with exit code $exitCode."
}
}
function Normalize-GitRemote {
param(
[Parameter(Mandatory = $true)]
[string]$Url
)
$normalized = $Url.Trim()
if ($normalized -match "^git@([^:]+):(.+)$") {
$normalized = "https://$($Matches[1])/$($Matches[2])"
}
elseif ($normalized -match "^ssh://git@([^/]+)/(.+)$") {
$normalized = "https://$($Matches[1])/$($Matches[2])"
}
$normalized = $normalized.TrimEnd("/")
if ($normalized.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
$normalized = $normalized.Substring(0, $normalized.Length - 4)
}
return $normalized.ToLowerInvariant()
}
function Assert-TemporaryChildWithoutReparsePoint {
param(
[Parameter(Mandatory = $true)]
[string]$TemporaryRoot,
[Parameter(Mandatory = $true)]
[string]$TargetPath
)
$root = [System.IO.Path]::GetFullPath($TemporaryRoot).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
$target = [System.IO.Path]::GetFullPath($TargetPath)
$rootPrefix = $root + [System.IO.Path]::DirectorySeparatorChar
if (-not $target.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "-SmokeDataPath must be a child of the Windows temporary directory."
}
if (
(Test-Path -LiteralPath $target) -and
-not (Test-Path -LiteralPath $target -PathType Container)
) {
throw "-SmokeDataPath exists but is not a directory."
}
$existingAncestor = $target
while (-not (Test-Path -LiteralPath $existingAncestor -PathType Container)) {
$parent = [System.IO.Directory]::GetParent($existingAncestor)
if ($null -eq $parent) {
throw "Could not resolve an existing ancestor for -SmokeDataPath."
}
$existingAncestor = $parent.FullName
}
if (
-not [string]::Equals(
$existingAncestor,
$root,
[System.StringComparison]::OrdinalIgnoreCase
) -and
-not $existingAncestor.StartsWith(
$rootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
)
) {
throw "-SmokeDataPath resolves through an ancestor outside the temporary directory."
}
if ($existingAncestor.Length -gt $rootPrefix.Length) {
$relativeAncestor = $existingAncestor.Substring($rootPrefix.Length)
$cursor = $root
foreach ($part in ($relativeAncestor -split "[\\/]")) {
if ([string]::IsNullOrWhiteSpace($part)) {
continue
}
$cursor = Join-Path -Path $cursor -ChildPath $part
$attributes = (Get-Item -LiteralPath $cursor -Force).Attributes
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "-SmokeDataPath must not traverse a junction, symlink, or other reparse point."
}
}
}
return $target
}
if ($Demo -and -not $Launch) {
throw "-Demo is only valid together with -Launch."
}
if ($Launch -and [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
throw "-Launch requires -SmokeDataPath so smoke runs cannot touch the normal app database."
}
if (-not $Launch -and -not [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
throw "-SmokeDataPath is only valid together with -Launch."
}
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".."))
$packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json"
$lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock"
$modulesManifestPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.modules.yaml"
$installedLockPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.pnpm\lock.yaml"
$workspaceLockPath = Join-Path -Path $projectRoot -ChildPath "pnpm-lock.yaml"
if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) {
throw "package.json was not found next to the scripts directory."
}
if (-not (Test-Path -LiteralPath $lappLockPath -PathType Leaf)) {
throw "lapp-rs.lock was not found in the project root."
}
$gitPath = Find-NativeCommand -Names @("git.exe", "git") -DisplayName "Git"
$rustupPath = Find-NativeCommand -Names @("rustup.exe", "rustup") -DisplayName "rustup"
$nodePath = Find-NativeCommand -Names @("node.exe", "node") -DisplayName "Node.js"
$pnpmPath = Find-NativeCommand -Names @("pnpm.cmd", "pnpm.exe", "pnpm") -DisplayName "pnpm"
$gitVersion = Invoke-NativeCapture -FilePath $gitPath -ArgumentList @("--version") -Label "Git version check"
Write-Host ("[ok] {0}" -f $gitVersion)
$installedToolchains = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("toolchain", "list") `
-Label "rustup toolchain check"
if ($installedToolchains -notmatch "(?m)^1\.96\.0(?:-|\s|$)") {
throw "Rust 1.96.0 is not installed. Install that toolchain outside this script and retry."
}
$rustVersion = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("run", "1.96.0", "rustc", "--version") `
-Label "Rust 1.96 version check"
if ($rustVersion -notmatch "^rustc 1\.96\.0(?:\s|$)") {
throw "Expected rustc 1.96.0, received: $rustVersion"
}
$rustVerboseVersion = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("run", "1.96.0", "rustc", "-vV") `
-Label "Rust host check"
if ($rustVerboseVersion -notmatch "(?m)^host:\s+\S+-pc-windows-msvc\s*$") {
throw "Rust 1.96.0 must use a *-pc-windows-msvc host for this Windows smoke."
}
$installedComponents = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("component", "list", "--toolchain", "1.96.0", "--installed") `
-Label "Rust component check"
if ($installedComponents -notmatch "(?m)^rustfmt-") {
throw "rustfmt is missing from Rust 1.96.0. Install it outside this script and retry."
}
if ($installedComponents -notmatch "(?m)^clippy-") {
throw "clippy is missing from Rust 1.96.0. Install it outside this script and retry."
}
Write-Host ("[ok] {0}; rustfmt and clippy are installed" -f $rustVersion)
$nodeVersion = Invoke-NativeCapture -FilePath $nodePath -ArgumentList @("--version") -Label "Node.js version check"
if ($nodeVersion -notmatch "^v(\d+)\.(\d+)\.(\d+)") {
throw "Could not parse the Node.js version: $nodeVersion"
}
$nodeMajor = [int]$Matches[1]
if ($nodeMajor -lt 24) {
throw "Node.js 24 or newer is required; received $nodeVersion."
}
Write-Host ("[ok] Node.js {0}" -f $nodeVersion)
$packageManifest = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json
$packageManager = [string]$packageManifest.packageManager
if ($packageManager -notmatch "^pnpm@(.+)$") {
throw "package.json does not declare an exact pnpm packageManager version."
}
$expectedPnpmVersion = $Matches[1]
$pnpmVersion = Invoke-NativeCapture -FilePath $pnpmPath -ArgumentList @("--version") -Label "pnpm version check"
if ($pnpmVersion -ne $expectedPnpmVersion) {
throw "pnpm $expectedPnpmVersion is required; received $pnpmVersion."
}
Write-Host ("[ok] pnpm {0}" -f $pnpmVersion)
$insideWorkTree = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "--is-inside-work-tree") `
-Label "nana-story Git checkout check"
if ($insideWorkTree -ne "true") {
throw "The project directory is not a Git working tree."
}
$projectBranch = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "branch", "--show-current") `
-Label "nana-story branch check"
if ($projectBranch -ne "integration/v1") {
throw "nana-story must be checked out on integration/v1; received '$projectBranch'."
}
$projectCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "HEAD") `
-Label "nana-story commit check"
if ($projectCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "nana-story HEAD is not a valid Git commit."
}
$projectWorkTreeStatus = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "status", "--porcelain=v1", "--untracked-files=all") `
-Label "nana-story clean-worktree check"
if (-not [string]::IsNullOrWhiteSpace($projectWorkTreeStatus)) {
throw "nana-story has tracked, staged, or untracked changes; smoke evidence would be ambiguous."
}
$projectUpstream = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") `
-Label "nana-story upstream check"
if ($projectUpstream -ne "origin/integration/v1") {
throw "integration/v1 must track origin/integration/v1; received '$projectUpstream'."
}
Invoke-NativeChecked `
-FilePath $gitPath `
-ArgumentList @(
"-C",
$projectRoot,
"fetch",
"--prune",
"origin",
"+refs/heads/integration/v1:refs/remotes/origin/integration/v1"
) `
-Label "fetch current origin/integration/v1"
$projectUpstreamCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", $projectUpstream) `
-Label "nana-story upstream commit check"
if ($projectUpstreamCommit -ne $projectCommit) {
throw "nana-story is ahead of or behind its local origin/integration/v1 reference. Pull the published branch and retry."
}
Write-Host (
"[ok] nana-story integration/v1 at {0}" -f $projectCommit.Substring(0, 12)
)
$lappLock = Get-Content -LiteralPath $lappLockPath -Raw
if ($lappLock -notmatch '(?m)^\s*commit\s*=\s*"([0-9a-fA-F]{40})"\s*$') {
throw "lapp-rs.lock does not contain a valid pinned commit."
}
$expectedLappCommit = $Matches[1].ToLowerInvariant()
if ($lappLock -notmatch '(?m)^\s*repository\s*=\s*"([^"]+)"\s*$') {
throw "lapp-rs.lock does not contain a repository."
}
$expectedLappRepository = Normalize-GitRemote -Url $Matches[1]
if ($lappLock -notmatch '(?m)^\s*development_path\s*=\s*"([^"]+)"\s*$') {
throw "lapp-rs.lock does not contain a development_path."
}
$lappDevelopmentPath = $Matches[1]
$lappPath = [System.IO.Path]::GetFullPath(
(Join-Path -Path $projectRoot -ChildPath $lappDevelopmentPath)
)
if (-not (Test-Path -LiteralPath (Join-Path -Path $lappPath -ChildPath "Cargo.toml") -PathType Leaf)) {
throw "The adjacent lapp-rs checkout required by lapp-rs.lock was not found."
}
$actualLappCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "rev-parse", "HEAD") `
-Label "lapp-rs commit check"
$actualLappCommit = $actualLappCommit.ToLowerInvariant()
if ($actualLappCommit -ne $expectedLappCommit) {
throw "lapp-rs is not at the commit pinned by lapp-rs.lock."
}
$actualLappRepository = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "remote", "get-url", "origin") `
-Label "lapp-rs origin check"
if ((Normalize-GitRemote -Url $actualLappRepository) -ne $expectedLappRepository) {
throw "lapp-rs origin does not match the repository pinned by lapp-rs.lock."
}
$lappWorkTreeStatus = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "status", "--porcelain=v1", "--untracked-files=all") `
-Label "lapp-rs clean-worktree check"
if (-not [string]::IsNullOrWhiteSpace($lappWorkTreeStatus)) {
throw "lapp-rs has tracked, staged, or untracked changes; Cargo would not be building the exact pin."
}
Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12))
# Keep all automated gates deterministic and noninteractive. No environment
# variables are enumerated or printed.
$env:CI = "true"
$env:NO_COLOR = "1"
$env:CARGO_TERM_COLOR = "never"
$env:RUSTUP_TOOLCHAIN = "1.96.0"
$env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0"
$previousStoryDataDir = [Environment]::GetEnvironmentVariable(
"NANA_STORY_SMOKE_DATA_DIR",
"Process"
)
$previousStoryProvider = [Environment]::GetEnvironmentVariable("NANA_STORY_PROVIDER", "Process")
$resolvedSmokeDataPath = $null
if ($Launch) {
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$resolvedSmokeDataPath = Assert-TemporaryChildWithoutReparsePoint `
-TemporaryRoot $temporaryRoot `
-TargetPath $SmokeDataPath
}
$locationPushed = $false
try {
Push-Location -LiteralPath $projectRoot
$locationPushed = $true
if ($InstallDependencies) {
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "install", "--frozen-lockfile") `
-Label "pnpm install --frozen-lockfile"
}
if (
-not (Test-Path -LiteralPath $modulesManifestPath -PathType Leaf) -or
-not (Test-Path -LiteralPath $installedLockPath -PathType Leaf)
) {
throw "Dependencies are missing. Re-run with -InstallDependencies to install them explicitly."
}
$modulesManifest = Get-Content -LiteralPath $modulesManifestPath -Raw | ConvertFrom-Json
if (
-not ($modulesManifest.PSObject.Properties.Name -contains "packageManager") -or
[string]$modulesManifest.packageManager -ne "pnpm@$expectedPnpmVersion"
) {
throw "node_modules was installed by a different pnpm version. Re-run with -InstallDependencies."
}
$workspaceLockHash = (Get-FileHash -LiteralPath $workspaceLockPath -Algorithm SHA256).Hash
$installedLockHash = (Get-FileHash -LiteralPath $installedLockPath -Algorithm SHA256).Hash
if ($workspaceLockHash -ne $installedLockHash) {
throw "node_modules does not match pnpm-lock.yaml. Re-run with -InstallDependencies."
}
Write-Host "[ok] node_modules matches the pinned pnpm version and lockfile."
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "verify") `
-Label "pnpm verify"
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "tauri", "build", "--no-bundle") `
-Label "pnpm tauri build --no-bundle"
Write-Host "[ok] Windows developer verification and non-bundled desktop build passed."
if ($Launch) {
New-Item -ItemType Directory -Path $resolvedSmokeDataPath -Force | Out-Null
$null = Assert-TemporaryChildWithoutReparsePoint `
-TemporaryRoot $temporaryRoot `
-TargetPath $resolvedSmokeDataPath
$env:NANA_STORY_SMOKE_DATA_DIR = $resolvedSmokeDataPath
if ($Demo) {
$env:NANA_STORY_PROVIDER = "demo"
Write-Host "[run] pnpm tauri dev (deterministic demo provider)"
}
else {
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
Write-Host "[run] pnpm tauri dev (default LAPP provider)"
}
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("tauri", "dev") `
-Label "pnpm tauri dev"
}
else {
Write-Host "[done] Re-run with -Launch, optionally with -Demo, to open the desktop app."
}
}
finally {
if ($locationPushed) {
Pop-Location
}
if ($null -eq $previousStoryDataDir) {
Remove-Item -Path Env:NANA_STORY_SMOKE_DATA_DIR -ErrorAction SilentlyContinue
}
else {
$env:NANA_STORY_SMOKE_DATA_DIR = $previousStoryDataDir
}
if ($null -eq $previousStoryProvider) {
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
}
else {
$env:NANA_STORY_PROVIDER = $previousStoryProvider
}
}