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
36 changes: 35 additions & 1 deletion .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand All @@ -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(
{
Expand Down Expand Up @@ -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,
Expand All @@ -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())

Expand Down
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -217,17 +227,37 @@ 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" }
} elseif (-not (Test-Path -LiteralPath (Join-Path $repository ".boatstack\runtime.json") -PathType Leaf)) {
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
}
Expand Down
9 changes: 6 additions & 3 deletions boatstack/internal/runtime/bootstrap_diagnostic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions boatstack/internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 26 additions & 15 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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"
Expand Down
46 changes: 31 additions & 15 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions release-notes/2026-08-14-runtime-hydration-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
Loading