From 0be275336e0437bb9f44fecd2fe9b26fe30dd896 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 03:26:36 +0100 Subject: [PATCH 1/2] Keep runtime hydration repository neutral --- .github/tests/test_repository_contract.py | 36 ++++++++++++++- .github/workflows/ci.yml | 32 ++++++++++++- .../internal/runtime/bootstrap_diagnostic.go | 9 ++-- boatstack/internal/runtime/runtime_test.go | 17 +++++++ install.ps1 | 41 +++++++++++------ install.sh | 46 +++++++++++++------ 6 files changed, 146 insertions(+), 35 deletions(-) diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index c5b76c0..d8eb8a6 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -979,6 +979,7 @@ def test_installer_hydrates_exact_local_runtime_without_repository_state(self) - "BOATSTACK_VERSION": version, "BOATSTACK_BINARY": str(self.helper), "BOATSTACK_BINARY_SHA256": digest, + "BOATSTACK_EXPECTED_RUNTIME_SHA256": digest, } ) @@ -994,6 +995,38 @@ def test_installer_hydrates_exact_local_runtime_without_repository_state(self) - "", ) + launcher = root / "bin" / "boatstack" + launcher_before = launcher.read_bytes() + self.run_command("git", "checkout", "--detach", cwd=repository) + detached = dict(env) + detached["BOATSTACK_HOME"] = str(root / "detached-home") + self.run_command("bash", REPO / "install.sh", cwd=repository, env=detached) + self.assertTrue( + (root / "detached-home" / "runtimes" / f"{version}-{digest}" / "boatstack-runtime").is_file() + ) + self.assertEqual(launcher.read_bytes(), launcher_before) + self.assertFalse((repository / ".boatstack").exists()) + self.assertFalse((repository / ".git" / "boatstack").exists()) + self.assertEqual( + self.run_command("git", "status", "--porcelain", cwd=repository).stdout, + "", + ) + + for name, variable in ( + ("missing-version", "BOATSTACK_VERSION"), + ("missing-digest", "BOATSTACK_EXPECTED_RUNTIME_SHA256"), + ): + incomplete = dict(env) + incomplete["BOATSTACK_HOME"] = str(root / f"{name}-home") + incomplete["BOATSTACK_INSTALL_DIR"] = str(root / f"{name}-bin") + incomplete.pop(variable, None) + incomplete_result = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=incomplete, expected=2, + ) + self.assertIn("BOATSTACK_RUNTIME_PIN_INVALID", incomplete_result.stderr) + self.assertFalse((root / f"{name}-home").exists()) + self.assertFalse((root / f"{name}-bin").exists()) + wrong_digest = dict(env) wrong_digest.update( { @@ -1030,7 +1063,7 @@ def test_installer_hydrates_exact_local_runtime_without_repository_state(self) - older_runtime.update( { "BOATSTACK_HOME": str(root / "old-home"), - "BOATSTACK_INSTALL_DIR": str(root / "old-bin"), + "BOATSTACK_INSTALL_DIR": str(root / "bin"), "BOATSTACK_VERSION": old_version, "BOATSTACK_BINARY": str(self.old_helper), "BOATSTACK_BINARY_SHA256": old_digest, @@ -1040,6 +1073,7 @@ def test_installer_hydrates_exact_local_runtime_without_repository_state(self) - self.run_command("bash", REPO / "install.sh", cwd=repository, env=older_runtime) restored = root / "old-home" / "runtimes" / f"{old_version}-{old_digest}" / "boatstack-runtime" self.assertTrue(restored.is_file()) + self.assertEqual(launcher.read_bytes(), launcher_before) self.assertFalse((repository / ".boatstack").exists()) self.assertFalse((repository / ".git" / "boatstack").exists()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 297c72b..cd15f18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,8 +203,18 @@ jobs: & git -C $repository add README.md & git -C $repository commit -m "Initialize fixture" | Out-Null + if ($mode -eq "hydrate") { + & git -C $repository checkout --detach | Out-Null + } + $runtimeHome = Join-Path $root "$mode-home" $installDir = Join-Path $root "$mode-bin" + $launcher = Join-Path $installDir "boatstack.exe" + if ($mode -eq "hydrate") { + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + [System.IO.File]::WriteAllBytes($launcher, [byte[]](1, 2, 3, 4)) + $launcherBefore = (Get-FileHash -Algorithm SHA256 -LiteralPath $launcher).Hash + } $env:BOATSTACK_REPO = $repository $env:BOATSTACK_HOME = $runtimeHome $env:BOATSTACK_INSTALL_DIR = $installDir @@ -217,10 +227,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" } $runtime = Join-Path $runtimeHome "runtimes\$version-$digest\boatstack-runtime.exe" - $launcher = Join-Path $installDir "boatstack.exe" if (-not (Test-Path -LiteralPath $runtime -PathType Leaf)) { throw "$mode did not stage the runtime" } if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw "$mode did not stage the launcher" } if ($mode -eq "hydrate") { + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $launcher).Hash -ne $launcherBefore) { throw "hydrate replaced the shared launcher" } if (Test-Path -LiteralPath (Join-Path $repository ".boatstack")) { throw "hydrate changed repository state" } if (Test-Path -LiteralPath (Join-Path $repository ".git\boatstack")) { throw "hydrate changed controller state" } if (& git -C $repository status --porcelain) { throw "hydrate changed tracked repository files" } @@ -228,6 +238,26 @@ jobs: throw "install did not initialize the repository runtime pin" } } + + $missingEvidenceRepository = Join-Path $root "missing-evidence-repository" + New-Item -ItemType Directory -Force -Path $missingEvidenceRepository | Out-Null + & git -C $missingEvidenceRepository init --initial-branch=main | Out-Null + $missingEvidenceHome = Join-Path $root "missing-evidence-home" + $missingEvidenceBin = Join-Path $root "missing-evidence-bin" + $env:BOATSTACK_REPO = $missingEvidenceRepository + $env:BOATSTACK_HOME = $missingEvidenceHome + $env:BOATSTACK_INSTALL_DIR = $missingEvidenceBin + $env:BOATSTACK_MODE = "hydrate" + $env:BOATSTACK_VERSION = "latest" + Remove-Item Env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -ErrorAction SilentlyContinue + try { + & ./install.ps1 + throw "hydrate without exact evidence succeeded" + } catch { + if ($_ -notmatch "BOATSTACK_RUNTIME_PIN_INVALID") { throw } + } + if (Test-Path -LiteralPath $missingEvidenceHome) { throw "invalid hydrate changed runtime storage" } + if (Test-Path -LiteralPath $missingEvidenceBin) { throw "invalid hydrate changed launcher storage" } } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/boatstack/internal/runtime/bootstrap_diagnostic.go b/boatstack/internal/runtime/bootstrap_diagnostic.go index d6704c2..beb73a3 100644 --- a/boatstack/internal/runtime/bootstrap_diagnostic.go +++ b/boatstack/internal/runtime/bootstrap_diagnostic.go @@ -95,15 +95,18 @@ func releaseInstallCommand(identity Identity, installerVersion string) string { } func requestedJSON(arguments []string) bool { + jsonRequested := false for index := 0; index < len(arguments); index++ { if arguments[index] == "--format" && index+1 < len(arguments) { - return arguments[index+1] == "json" + jsonRequested = arguments[index+1] == "json" + index++ + continue } if strings.TrimPrefix(arguments[index], "--format=") != arguments[index] { - return strings.TrimPrefix(arguments[index], "--format=") == "json" + jsonRequested = strings.TrimPrefix(arguments[index], "--format=") == "json" } } - return false + return jsonRequested } func RenderBootstrapDiagnostic(writer io.Writer, err error, arguments []string) (bool, error) { diff --git a/boatstack/internal/runtime/runtime_test.go b/boatstack/internal/runtime/runtime_test.go index c07a6df..9971225 100644 --- a/boatstack/internal/runtime/runtime_test.go +++ b/boatstack/internal/runtime/runtime_test.go @@ -219,6 +219,23 @@ func TestBootstrapDiagnosticRenderingPreservesOneEnvelope(t *testing.T) { if rendered, err := RenderBootstrapDiagnostic(&unrelated, errors.New("ordinary"), nil); err != nil || rendered || unrelated.Len() != 0 { t.Fatalf("ordinary error render = rendered %t, err %v, output %q", rendered, err, unrelated.String()) } + + for _, test := range []struct { + arguments []string + wantJSON bool + }{ + {arguments: []string{"next", "--format=text", "--format=json"}, wantJSON: true}, + {arguments: []string{"next", "--format", "json", "--format", "text"}, wantJSON: false}, + } { + var output bytes.Buffer + if rendered, err := RenderBootstrapDiagnostic(&output, diagnostic, test.arguments); err != nil || !rendered { + t.Fatalf("repeated format rendered=%v err=%v", rendered, err) + } + gotJSON := strings.HasPrefix(output.String(), "{") + if gotJSON != test.wantJSON { + t.Fatalf("arguments %v rendered JSON=%v, want %v: %s", test.arguments, gotJSON, test.wantJSON, output.String()) + } + } } func TestUnreleasedRuntimeIdentityHasNoDownloadCommand(t *testing.T) { diff --git a/install.ps1 b/install.ps1 index bd39897..19fcfe6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,23 +11,32 @@ $InstallDir = if ($env:BOATSTACK_INSTALL_DIR) { $env:BOATSTACK_INSTALL_DIR } els $BoatstackHome = if ($env:BOATSTACK_HOME) { $env:BOATSTACK_HOME } else { Join-Path $env:LOCALAPPDATA "Boatstack" } if ($Mode -notin @("install", "update", "hydrate")) { throw "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" } +if ($Mode -eq "hydrate") { + if ($Version -eq "latest") { throw "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_VERSION" } + if (-not $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -or $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -notmatch '^[0-9A-Fa-f]{64}$') { + throw "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_EXPECTED_RUNTIME_SHA256" + } +} $RepositoryOutput = & git -C $Repository rev-parse --show-toplevel $RepositoryStatus = $LASTEXITCODE if ($RepositoryStatus -ne 0 -or -not $RepositoryOutput) { throw "Boatstack installation requires a Git repository" } $Repository = ($RepositoryOutput -join "`n").Trim() -$CurrentBranchOutput = & git -C $Repository symbolic-ref --quiet --short HEAD -$CurrentBranchStatus = $LASTEXITCODE -if ($CurrentBranchStatus -ne 0 -or -not $CurrentBranchOutput) { throw "Boatstack installation requires an attached branch" } -$CurrentBranch = ($CurrentBranchOutput -join "`n").Trim() -$RemoteDefaultOutput = & git -C $Repository symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>$null -$RemoteDefaultStatus = $LASTEXITCODE -$DefaultBranch = if ($RemoteDefaultStatus -eq 0 -and $RemoteDefaultOutput) { - (($RemoteDefaultOutput -join "`n").Trim() -replace '^origin/', '') -} else { - $CurrentBranch +$DefaultBranch = $null +if ($Mode -ne "hydrate") { + $CurrentBranchOutput = & git -C $Repository symbolic-ref --quiet --short HEAD + $CurrentBranchStatus = $LASTEXITCODE + if ($CurrentBranchStatus -ne 0 -or -not $CurrentBranchOutput) { throw "Boatstack installation requires an attached branch" } + $CurrentBranch = ($CurrentBranchOutput -join "`n").Trim() + $RemoteDefaultOutput = & git -C $Repository symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>$null + $RemoteDefaultStatus = $LASTEXITCODE + $DefaultBranch = if ($RemoteDefaultStatus -eq 0 -and $RemoteDefaultOutput) { + (($RemoteDefaultOutput -join "`n").Trim() -replace '^origin/', '') + } else { + $CurrentBranch + } + & git check-ref-format --branch $DefaultBranch | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Boatstack could not resolve a valid default branch" } } -& git check-ref-format --branch $DefaultBranch | Out-Null -if ($LASTEXITCODE -ne 0) { throw "Boatstack could not resolve a valid default branch" } $Architecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) { "x64" { "amd64" } "arm64" { "arm64" } @@ -125,9 +134,11 @@ try { New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $Launcher = Join-Path $InstallDir "boatstack.exe" - $StagedLauncher = Join-Path $InstallDir (".boatstack-" + [guid]::NewGuid().ToString("N") + ".exe") - Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher - Move-Item -LiteralPath $StagedLauncher -Destination $Launcher -Force + if ($Mode -ne "hydrate" -or -not (Test-Path -LiteralPath $Launcher)) { + $StagedLauncher = Join-Path $InstallDir (".boatstack-" + [guid]::NewGuid().ToString("N") + ".exe") + Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher + Move-Item -LiteralPath $StagedLauncher -Destination $Launcher -Force + } Write-Host "Boatstack installed at $Runtime" if ($Mode -ne "hydrate") { Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host skills" diff --git a/install.sh b/install.sh index 0cffe97..4408c25 100755 --- a/install.sh +++ b/install.sh @@ -18,20 +18,34 @@ case "$mode" in *) echo "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" >&2; exit 2 ;; esac +if [[ "$mode" == hydrate ]]; then + [[ "$version" != latest ]] || { + echo "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_VERSION" >&2 + exit 2 + } + [[ "${BOATSTACK_EXPECTED_RUNTIME_SHA256:-}" =~ ^[0-9a-fA-F]{64}$ ]] || { + echo "BOATSTACK_RUNTIME_PIN_INVALID: hydrate requires an exact BOATSTACK_EXPECTED_RUNTIME_SHA256" >&2 + exit 2 + } +fi + repository="$(git -C "$repository" rev-parse --show-toplevel)" -current_branch="$(git -C "$repository" symbolic-ref --quiet --short HEAD)" || { - echo "Boatstack installation requires an attached branch" >&2 - exit 2 -} -remote_default="$(git -C "$repository" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" -default_branch="${remote_default#origin/}" -if [[ -z "$default_branch" ]]; then - default_branch="$current_branch" +default_branch="" +if [[ "$mode" != hydrate ]]; then + current_branch="$(git -C "$repository" symbolic-ref --quiet --short HEAD)" || { + echo "Boatstack installation requires an attached branch" >&2 + exit 2 + } + remote_default="$(git -C "$repository" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + default_branch="${remote_default#origin/}" + if [[ -z "$default_branch" ]]; then + default_branch="$current_branch" + fi + git check-ref-format --branch "$default_branch" >/dev/null || { + echo "Boatstack could not resolve a valid default branch" >&2 + exit 2 + } fi -git check-ref-format --branch "$default_branch" >/dev/null || { - echo "Boatstack could not resolve a valid default branch" >&2 - exit 2 -} case "$(uname -s)" in Darwin) os=darwin ;; Linux) os=linux ;; @@ -140,9 +154,11 @@ elif [[ "$mode" == update ]]; then fi mkdir -p "$install_dir" -launcher_staged="$install_dir/.boatstack.$$" -install -m 0755 "$candidate" "$launcher_staged" -mv -f "$launcher_staged" "$install_dir/boatstack" +if [[ "$mode" != hydrate || ! -e "$install_dir/boatstack" ]]; then + launcher_staged="$install_dir/.boatstack.$$" + install -m 0755 "$candidate" "$launcher_staged" + mv -f "$launcher_staged" "$install_dir/boatstack" +fi echo "Boatstack installed at $runtime" if [[ "$mode" != hydrate ]]; then From 708091882f6f86a4e16f44a7926557a803874168 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 03:31:32 +0100 Subject: [PATCH 2/2] Document runtime hydration boundary --- release-notes/2026-08-14-runtime-hydration-boundary.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 release-notes/2026-08-14-runtime-hydration-boundary.md diff --git a/release-notes/2026-08-14-runtime-hydration-boundary.md b/release-notes/2026-08-14-runtime-hydration-boundary.md new file mode 100644 index 0000000..86cc4d0 --- /dev/null +++ b/release-notes/2026-08-14-runtime-hydration-boundary.md @@ -0,0 +1,3 @@ +### Keep runtime hydration repository neutral + +Exact runtime hydration now works from detached checkouts, requires the pinned version and checksum, and preserves an existing shared launcher.