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
5 changes: 5 additions & 0 deletions .changeset/fix-pnpm-bun-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Stop installing Bun beside prebuilt Hunk packages so pnpm global updates cannot corrupt Bun's shared platform-package projection. Standalone platform binaries continue to work without a separate Bun installation.
62 changes: 62 additions & 0 deletions .github/workflows/install-vm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Runs repository-controlled KVM code only when a maintainer explicitly requests it.
# Never add pull_request or pull_request_target triggers to this privilege-bearing workflow.
name: Optional install VM suite

on:
workflow_dispatch:
inputs:
scenario:
description: Optional scenario id (empty runs the full suite)
required: false
type: string

permissions:
contents: read

concurrency:
group: install-vm-${{ github.ref }}
cancel-in-progress: true

jobs:
install-vm:
name: Firecracker install compatibility
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14

- name: Install ordinary development dependencies
run: bun install --frozen-lockfile

- name: Run selected install scenarios
env:
INSTALL_VM_SCENARIO: ${{ inputs.scenario }}
run: |
args=(--allow-skip)
if [[ -n "$INSTALL_VM_SCENARIO" ]]; then
args+=(--scenario "$INSTALL_VM_SCENARIO")
fi
bun run test:install-vm -- "${args[@]}"

- name: Upload structured install results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: install-vm-results
path: |
tmp/install-vm/runs
!tmp/install-vm/**/*.ext4
!tmp/install-vm/**/*.socket
!tmp/install-vm/**/id_*
!tmp/install-vm/**/.lock/**
!tmp/install-vm/**/*identity*
!tmp/install-vm/**/*credential*
if-no-files-found: warn
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
"test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast",
"test:integration": "\"${npm_execpath:-bun}\" test ./test/pty",
"test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke",
"test:install-vm": "bun run ./test/cli/install-vm/runner.ts",
"test:install-vm:clean": "bun run ./test/cli/install-vm/runner.ts --clean",
"check:pack": "bun run ./scripts/check-pack.ts",
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
"smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
Expand Down
10 changes: 5 additions & 5 deletions scripts/check-prebuilt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import {
assertNoMandatoryBunDependency,
assertOptionalPeerDependencyContract,
releaseNpmDir,
type PackageDependencyManifest,
Expand Down Expand Up @@ -74,11 +75,10 @@ if (!existsSync(metaDir)) {
throw new Error(`Missing staged top-level package at ${metaDir}`);
}

assertOptionalPeerDependencyContract(
readPackageManifest(repoRoot),
readPackageManifest(metaDir),
"@pierre/diffs",
);
const rootManifest = readPackageManifest(repoRoot);
const stagedManifest = readPackageManifest(metaDir);
assertOptionalPeerDependencyContract(rootManifest, stagedManifest, "@pierre/diffs");
assertNoMandatoryBunDependency(stagedManifest);

const metaPack = runPackDryRun(metaDir);
assertPaths(metaPack, [
Expand Down
19 changes: 19 additions & 0 deletions scripts/prebuilt-package-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, test } from "bun:test";
import {
PLATFORM_PACKAGE_MATRIX,
assertNoMandatoryBunDependency,
assertOptionalPeerDependencyContract,
binaryFilenameForSpec,
buildOptionalDependencyMap,
buildPlatformPackageManifest,
buildPrebuiltRuntimeDependencies,
getHostPlatformPackageSpec,
getPlatformPackageSpecByName,
getPlatformPackageSpecForHost,
Expand Down Expand Up @@ -34,6 +36,23 @@ function createOptionalPeerContract(): {
}

describe("prebuilt package helpers", () => {
test("prebuilt runtime dependencies exclude Bun without mutating the source manifest", () => {
const dependencies = { bun: "^1.3.14", commander: "^14.0.3" };

expect(buildPrebuiltRuntimeDependencies(dependencies)).toEqual({ commander: "^14.0.3" });
expect(dependencies).toEqual({ bun: "^1.3.14", commander: "^14.0.3" });
expect(buildPrebuiltRuntimeDependencies()).toBeUndefined();
});

test("assertNoMandatoryBunDependency rejects a staged Bun runtime", () => {
expect(() =>
assertNoMandatoryBunDependency({ dependencies: { commander: "1.0.0" } }),
).not.toThrow();
expect(() => assertNoMandatoryBunDependency({ dependencies: { bun: "1.4.0" } })).toThrow(
"omit the mandatory bun dependency",
);
});

test("buildOptionalDependencyMap includes every supported platform package at one version", () => {
const version = "9.9.9";
const dependencies = buildOptionalDependencyMap(version);
Expand Down
15 changes: 15 additions & 0 deletions scripts/prebuilt-package-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ export interface PackageDependencyManifest {
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
}

/** Remove Bun from dependencies shipped beside standalone prebuilt executables. */
export function buildPrebuiltRuntimeDependencies(dependencies?: Record<string, string>) {
if (!dependencies) return undefined;

const { bun: _bundledRuntime, ...runtimeDependencies } = dependencies;
return runtimeDependencies;
}

/** Assert a staged prebuilt package cannot install Bun as a mandatory dependency. */
export function assertNoMandatoryBunDependency(manifest: PackageDependencyManifest) {
if (manifest.dependencies?.bun !== undefined) {
throw new Error("Expected the staged prebuilt package to omit the mandatory bun dependency.");
}
}

const PLATFORM_NAME_MAP: Partial<Record<NodeJS.Platform, SupportedPlatform>> = {
darwin: "darwin",
linux: "linux",
Expand Down
26 changes: 26 additions & 0 deletions scripts/smoke-prebuilt-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
Expand All @@ -19,6 +20,27 @@ import {
} from "./prebuilt-package-helpers";
import { envWithPath, npmCommand } from "./script-helpers";

/** Return whether an installed dependency tree contains Bun's npm packages. */
function containsBunPackage(root: string) {
if (!existsSync(root)) return false;

const pending = [root];
while (pending.length > 0) {
const directory = pending.pop()!;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name === "bun" && path.basename(directory) === "node_modules") return true;
if (entry.name.startsWith("bun-") && path.basename(directory) === "@oven") return true;
pending.push(entryPath);
}
}
}

return false;
}

function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
const proc = Bun.spawnSync(command, {
cwd: options?.cwd,
Expand Down Expand Up @@ -150,6 +172,10 @@ try {
throw new Error("Expected a CLI-only Hunk install to omit the optional @pierre/diffs peer.");
}

if (containsBunPackage(installDir)) {
throw new Error("Expected a prebuilt Hunk install to omit bun and @oven/bun-* packages.");
}

if (process.platform !== "win32") {
const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777;
if ((installedBinaryMode & 0o111) === 0) {
Expand Down
3 changes: 2 additions & 1 deletion scripts/stage-prebuilt-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
binaryFilenameForSpec,
buildOptionalDependencyMap,
buildPlatformPackageManifest,
buildPrebuiltRuntimeDependencies,
getHostPlatformPackageSpec,
getPlatformPackageSpecByName,
releaseNpmDir,
Expand Down Expand Up @@ -106,7 +107,7 @@ function stageMetaPackage(
homepage: rootPackage.homepage,
bugs: rootPackage.bugs,
engines: rootPackage.engines,
dependencies: rootPackage.dependencies,
dependencies: buildPrebuiltRuntimeDependencies(rootPackage.dependencies),
peerDependencies: rootPackage.peerDependencies,
peerDependenciesMeta: rootPackage.peerDependenciesMeta,
optionalDependencies: buildOptionalDependencyMap(rootPackage.version, specs),
Expand Down
25 changes: 24 additions & 1 deletion skills/hunk-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,30 @@ bun run check:prebuilt-pack
bun run smoke:prebuilt-install
```

Commit the generated metadata and `benchmarks/release/bench-X.Y.Z.json`, follow normal review policy, and wait for required CI.
Commit the generated metadata and `benchmarks/release/bench-X.Y.Z.json`, then follow normal review
policy. The Firecracker evidence must come from that reviewed release tip, not the pre-generation
commit. Push the reviewed tip before using the manual workflow.

Run the full Firecracker install compatibility suite once from the clean reviewed release tip on a
Linux x64 host with working KVM, either locally or through the manually dispatched
`install-vm.yml` workflow:

```sh
set -euo pipefail
mkdir -p tmp/install-vm/runs
result_dir=$(mktemp -d tmp/install-vm/runs/release-XXXXXXXX)
bun run test:install-vm -- --output "$result_dir"
bun run ./test/cli/install-vm/validate-release-result.ts "$result_dir/result.json"
```

The explicit output directory prevents a failed invocation from falling back to stale evidence. The
validator requires the complete checked-in scenario manifest, passing statuses, and the current
checkout's source identity. A skipped result does not satisfy release validation. For the manual
workflow, dispatch the full suite from the reviewed tip, download its `result.json` beneath the
ignored `tmp/install-vm/` directory of a checkout at that exact tip, and run the validator there; a
green job alone is insufficient because unsupported runners may use the intentional skip path.
Firecracker validates Linux x64 packaging behavior, while the existing native release jobs remain
responsible for macOS, Windows, and other architectures. Wait for required CI before continuing.

## 3. Tag and publish

Expand Down
8 changes: 8 additions & 0 deletions test/cli/install-vm/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**
!Dockerfile
!controller.sh
!pins.json
!scenarios.json
!controller-deps/**
!guest/**
!scenarios/**
43 changes: 43 additions & 0 deletions test/cli/install-vm/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# The runner always overrides this parse-safe default with the digest from pins.json.
ARG CONTROLLER_IMAGE=scratch
FROM ${CONTROLLER_IMAGE}

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates=20260601~24.04.1 \
curl=8.5.0-2ubuntu10.13 \
e2fsprogs=1.47.0-2.4~exp1ubuntu4.1 \
iproute2=6.1.0-1ubuntu6.4 \
iptables=1.8.10-3ubuntu2 \
jq=1.7.1-3ubuntu0.24.04.2 \
openssh-client=1:9.6p1-3ubuntu13.18 \
procps=2:4.0.4-4ubuntu3.3 \
python3=3.12.3-0ubuntu2.1 \
squashfs-tools=1:4.6.1-1build1 \
xz-utils=5.6.1+really5.4.5-1ubuntu0.3 \
&& rm -rf /var/lib/apt/lists/*

ARG NODE_VERSION
ARG NODE_URL
ARG NODE_SHA256
RUN curl --fail --show-error --location --connect-timeout 15 --max-time 300 --retry 3 \
-o /tmp/node.tar.xz "${NODE_URL}" \
&& echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \
&& tar -xJf /tmp/node.tar.xz -C /opt \
&& ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/node" /usr/local/bin/node \
&& ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/npm" /usr/local/bin/npm \
&& ln -s "/opt/node-v${NODE_VERSION}-linux-x64/bin/npx" /usr/local/bin/npx \
&& rm -f /tmp/node.tar.xz

WORKDIR /opt/install-vm
COPY controller-deps ./controller-deps
RUN cd controller-deps \
&& npm ci --ignore-scripts \
&& ln -s /opt/install-vm/controller-deps/node_modules/.bin/verdaccio /usr/local/bin/verdaccio
COPY controller.sh pins.json scenarios.json ./
COPY guest ./guest
COPY scenarios ./scenarios
RUN chmod +x controller.sh guest/*.sh scenarios/*.sh

ENTRYPOINT ["/opt/install-vm/controller.sh"]
43 changes: 43 additions & 0 deletions test/cli/install-vm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Optional Firecracker install compatibility suite

This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets.

## Run

Requirements:

- Linux x86_64 with at least 6 GiB free;
- Docker daemon access without `sudo`;
- readable/writable `/dev/kvm` and `/dev/net/tun`.

```sh
bun run test:install-vm -- --list
bun run test:install-vm -- --scenario pnpm-global-upgrade
bun run test:install-vm
```

Use `--reuse-fixtures` to reuse package fixtures only when their checkout identity and every tarball checksum still match; stale or altered fixtures are rebuilt. Automation that intentionally permits unsupported hosts may pass `--allow-skip`; a requested local run otherwise fails with an actionable preflight report. In GitHub Actions, an allowed skip emits a workflow warning and a prominent step summary in addition to a structured skipped result—it is not VM success.

The first run lazily builds the controller image and downloads checksum-pinned Firecracker, kernel, rootfs, and Node inputs. They live under `tmp/install-vm/cache`; generated package fixtures and structured runs live under `tmp/install-vm/fixtures` and `tmp/install-vm/runs`. Remove only those harness-owned artifacts with:

```sh
bun run test:install-vm:clean
```

The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because deleting a lock
owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and
remove that lock directory manually before retrying.

Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts <result.json>`.

## Security boundary

The controller container receives only `/dev/kvm`, `/dev/net/tun`, `NET_ADMIN`, `CHOWN`, and `DAC_OVERRIDE`. The last two let it traverse the owner-only validated cache/result binds while running, then return their ownership to the invoking user; the directories are never made world-writable. The container drops all other capabilities, enables `no-new-privileges`, uses a read-only container root, and never mounts the repository or Docker socket. TAP and NAT changes stay in its Docker network namespace and are removed on exit. Third-party package lifecycle scripts run as root only inside disposable guests with no repository, host credentials, or host-writable package cache.

This is development/test isolation, not a production Firecracker jail. Run repository-controlled KVM jobs only on trusted disposable hosts. The dedicated workflow is manual and never runs for pull requests.

## Coverage boundaries

Firecracker runs Linux guests on the host CPU. It cannot validate macOS or Windows, emulate Apple Silicon, or reproduce the final native macOS ARM64 exit behavior from issue #866. Native Apple Silicon coverage remains tracked by `TODO-1994d3d9`.

The Node-resolvable npm Bun fallback remains a best-effort legacy path. A standalone `bun` on PATH is deliberately different and is not used by the launcher. The suite observes an older fallback package but does not declare a supported minimum Bun version.
Loading
Loading