diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 9f7d919..4a93f6b 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -15,7 +15,8 @@ negative cases, cancellation/recovery and synthetic LightBurn imports. The repository now provides: - an on-demand source/build entry point that never installs the Windows - toolchain; + toolchain, rejects modified upstream source and replaces only the validated + addon staging directory; - a non-executing verifier for externally produced Windows artifacts; it requires an exact repository-to-manifest-to-ZIP addon file set; - a checksummed acceptance-kit generator containing only allowlisted public diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index 2f0edfb..1bf435f 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -39,8 +39,12 @@ toolchain preparation remains a separate, explicitly invoked upstream action or should happen on a dedicated build runner. Before staging, the script rejects transport imports in the addon. The build -manifest records the exact upstream commit and hashes every addon source file, -the portable archive and installer. It does not claim the GUI has been tested. +entry point also rejects tracked Rayforge modifications and any unexpected +untracked source. It removes and recreates only the validated addon staging +directory, preventing stale files from a previous run from entering the +bundle. The build manifest records the exact upstream commit and hashes every +addon source file, the portable archive and installer. It does not claim the +GUI has been tested. Copy the ZIP, installer and JSON manifest together, then verify an externally produced checkpoint without launching either binary: diff --git a/docs/research/rayforge-windows-gui-preflight.md b/docs/research/rayforge-windows-gui-preflight.md index f8b9896..62777c0 100644 --- a/docs/research/rayforge-windows-gui-preflight.md +++ b/docs/research/rayforge-windows-gui-preflight.md @@ -45,6 +45,12 @@ revision into the ignored `.build/upstreams/` cache only when explicitly invoked. Its default action prepares source only; it neither installs the Windows toolchain nor starts a build. +Source preparation also verifies the Git worktree state. Tracked changes and +unexpected untracked source fail closed. The Windows checkpoint wrapper allows +only its known addon staging subtree, validates that path, removes it before +copying, and then rechecks the worktree before invoking upstream's build. +Ignored upstream build outputs remain outside this source-integrity decision. + The repository scripts do not install Windows dependencies. Bundle creation requires the explicit `-BuildWindowsBundle` switch and an already reviewed, prepared runtime. This keeps a multi-gigabyte MSYS2/UCRT64 toolchain out of diff --git a/docs/roadmap.md b/docs/roadmap.md index 798a007..74d3c6a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -321,7 +321,9 @@ The transport-free portions are now consolidated by the [M1 no-device acceptance checkpoint](research/m1-no-device-acceptance.md). It reruns the public suite and leak guard and hashes the validated regression reports. The on-demand build entry point fetches pinned source and stages the -addon but deliberately refuses to install a missing Windows toolchain. +addon but deliberately refuses to install a missing Windows toolchain. It +rejects modified upstream source and replaces the exact addon staging tree so +stale files cannot survive between builds. An artifact produced elsewhere can be checked locally without execution: the verifier rejects unsafe paths or changed bytes and confirms the pin plus every packaged addon source before the manual GUI checklist may begin. It also diff --git a/scripts/build_rayforge_windows_checkpoint.ps1 b/scripts/build_rayforge_windows_checkpoint.ps1 index cf951f2..8a2300a 100644 --- a/scripts/build_rayforge_windows_checkpoint.ps1 +++ b/scripts/build_rayforge_windows_checkpoint.ps1 @@ -20,7 +20,8 @@ if (-not $ArtifactRoot) { } & (Join-Path $PSScriptRoot "prepare_rayforge_build.ps1") ` - -CacheRoot $CacheRoot + -CacheRoot $CacheRoot ` + -AllowStagedPirateRayAddon if ($LASTEXITCODE -ne 0) { throw "Pinned Rayforge source preparation failed" } @@ -48,6 +49,17 @@ $addonDestination = Join-Path $checkout ( "rayforge\builtin_addons\rayforge-addon-the-pirate-ray-jcz" ) if ($PSCmdlet.ShouldProcess($addonDestination, "Stage no-device addon")) { + $resolvedCheckout = [IO.Path]::GetFullPath($checkout).TrimEnd("\") + $resolvedAddonDestination = [IO.Path]::GetFullPath($addonDestination) + $expectedAddonDestination = Join-Path $resolvedCheckout ( + "rayforge\builtin_addons\rayforge-addon-the-pirate-ray-jcz" + ) + if ($resolvedAddonDestination -ne $expectedAddonDestination) { + throw "Refusing to replace an unexpected addon staging path" + } + if (Test-Path -LiteralPath $resolvedAddonDestination) { + Remove-Item -LiteralPath $resolvedAddonDestination -Recurse -Force + } New-Item -ItemType Directory -Force -Path $addonDestination | Out-Null Copy-Item -Path (Join-Path $addonSource "*") ` -Destination $addonDestination -Recurse -Force @@ -56,6 +68,7 @@ if ($PSCmdlet.ShouldProcess($addonDestination, "Stage no-device addon")) { $prepareArguments = @{ CacheRoot = $CacheRoot BuildWindowsBundle = $true + AllowStagedPirateRayAddon = $true } & (Join-Path $PSScriptRoot "prepare_rayforge_build.ps1") @prepareArguments if ($LASTEXITCODE -ne 0) { diff --git a/scripts/prepare_rayforge_build.ps1 b/scripts/prepare_rayforge_build.ps1 index 2c6c7da..9d17354 100644 --- a/scripts/prepare_rayforge_build.ps1 +++ b/scripts/prepare_rayforge_build.ps1 @@ -1,7 +1,8 @@ [CmdletBinding(SupportsShouldProcess)] param( [string]$CacheRoot, - [switch]$BuildWindowsBundle + [switch]$BuildWindowsBundle, + [switch]$AllowStagedPirateRayAddon ) $ErrorActionPreference = "Stop" @@ -54,8 +55,23 @@ if ($verifiedCommit -ne $manifest.commit) { throw "Rayforge pin mismatch: expected $($manifest.commit), got $verifiedCommit" } +$statusLines = @(& git -C $checkout status --porcelain=v1 --untracked-files=all) +if ($LASTEXITCODE -ne 0) { + throw "Could not inspect the Rayforge checkout state" +} +$allowedAddonPrefix = "?? rayforge/builtin_addons/rayforge-addon-the-pirate-ray-jcz/" +$unexpectedChanges = @( + $statusLines | Where-Object { + $_ -and (-not $AllowStagedPirateRayAddon -or -not $_.StartsWith($allowedAddonPrefix)) + } +) +if ($unexpectedChanges.Count -gt 0) { + throw "Rayforge checkout contains unreviewed tracked or untracked changes" +} + Write-Output "Rayforge source ready at $checkout" Write-Output "Verified commit: $verifiedCommit" +Write-Output "Verified checkout state: clean except explicitly staged PirateRay addon" if (-not $BuildWindowsBundle) { Write-Output "No dependencies were installed and no build was started." diff --git a/tests/test_m1_acceptance_pipeline.py b/tests/test_m1_acceptance_pipeline.py index 35311f8..f088803 100644 --- a/tests/test_m1_acceptance_pipeline.py +++ b/tests/test_m1_acceptance_pipeline.py @@ -47,6 +47,9 @@ class M1AcceptancePipelineTests(unittest.TestCase): self.assertIn("schema_version = 2", source) self.assertIn("Rayforge-LICENSE.txt", source) self.assertIn("license_files", source) + self.assertIn("GetFullPath", source) + self.assertIn("Remove-Item -LiteralPath $resolvedAddonDestination", source) + self.assertIn("AllowStagedPirateRayAddon", source) def test_acceptance_has_no_hardware_commands(self): source = ACCEPTANCE.read_text(encoding="utf-8").lower() diff --git a/tests/test_upstream_build_reference.py b/tests/test_upstream_build_reference.py index 3d6ad8d..950895d 100644 --- a/tests/test_upstream_build_reference.py +++ b/tests/test_upstream_build_reference.py @@ -24,6 +24,9 @@ class UpstreamBuildReferenceTests(unittest.TestCase): self.assertNotIn("InstallWindowsDependencies", script) self.assertIn("BuildWindowsBundle", script) self.assertIn("No dependencies were installed", script) + self.assertIn("status --porcelain=v1 --untracked-files=all", script) + self.assertIn("unreviewed tracked or untracked changes", script) + self.assertIn("AllowStagedPirateRayAddon", script) def test_prepare_script_has_no_hardware_path(self): script = PREPARE.read_text(encoding="utf-8").lower()