fix(FN-2462): enforce frozen-lockfile bootstrap across workflows

- Update CI, version, and test-release workflows to use pnpm install --frozen-lockfile consistently.
- Align contributor and settings documentation plus worktree init examples toward deterministic frozen-lockfile bootstrap.
- Clarify TaskExecutor worktree init guidance to distinguish dependency bootstrap failures from missing workspace dist export failures.
- Refresh workflow, executor, restart, and SettingsModal tests (including stable Node Sync tab selection) to assert the new bootstrap contract.
This commit is contained in:
Fusion
2026-04-24 11:41:10 -07:00
committed by gsxdsm
parent e480b8d411
commit 89b5b5d68c
12 changed files with 49 additions and 32 deletions

View File

@@ -27,7 +27,7 @@ jobs:
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install --frozen-lockfile
- name: Verify workspace bootstrap contract (lint -> test -> build) - name: Verify workspace bootstrap contract (lint -> test -> build)
run: pnpm verify:workspace run: pnpm verify:workspace

View File

@@ -51,7 +51,7 @@ jobs:
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: pnpm build run: pnpm build

View File

@@ -41,7 +41,7 @@ jobs:
run: npm install -g npm@11.6.4 run: npm install -g npm@11.6.4
- name: Install dependencies - name: Install dependencies
run: pnpm install --no-frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: pnpm build run: pnpm build

View File

@@ -16,7 +16,7 @@ Thanks for contributing to Fusion.
### Install dependencies ### Install dependencies
```bash ```bash
pnpm install pnpm install --frozen-lockfile
``` ```
### Build all packages ### Build all packages
@@ -54,6 +54,7 @@ pnpm typecheck # workspace typechecks
Fusion codifies workspace verification as a deterministic contract: Fusion codifies workspace verification as a deterministic contract:
- Use `pnpm install --frozen-lockfile` for clean bootstrap and dependency repair paths.
- `pnpm test` must be runnable in a clean worktree without requiring a prior `pnpm build`. - `pnpm test` must be runnable in a clean worktree without requiring a prior `pnpm build`.
- This includes clean states where `packages/core/dist`, `packages/engine/dist`, and `packages/dashboard/dist` are absent. - This includes clean states where `packages/core/dist`, `packages/engine/dist`, and `packages/dashboard/dist` are absent.
- `pnpm verify:workspace` is the canonical pre-merge gate and runs in strict order: - `pnpm verify:workspace` is the canonical pre-merge gate and runs in strict order:

View File

@@ -88,7 +88,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. |
| `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. |
| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. | | `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. For pnpm repos, prefer `pnpm install --frozen-lockfile` for deterministic bootstrap. |
| `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. |
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
| `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. | | `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. |

View File

@@ -52,8 +52,10 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
expect(workflow.on.pull_request).toBeUndefined(); expect(workflow.on.pull_request).toBeUndefined();
}); });
it("includes pnpm install step", () => { it("pins dependency bootstrap to frozen lockfile", () => {
expect(content).toContain("pnpm install"); expect(content).toContain("run: pnpm install --frozen-lockfile");
expect(content).not.toContain("run: pnpm install\n");
expect(content).not.toContain("--no-frozen-lockfile");
}); });
it("uses verify:workspace as the single lint/test/build contract", () => { it("uses verify:workspace as the single lint/test/build contract", () => {
@@ -120,8 +122,10 @@ describe("Version & Release workflow (.github/workflows/version.yml)", () => {
expect(workflow.on.push).toBeUndefined(); expect(workflow.on.push).toBeUndefined();
}); });
it("includes pnpm install step", () => { it("pins release bootstrap to frozen lockfile", () => {
expect(content).toContain("pnpm install"); expect(content).toContain("run: pnpm install --frozen-lockfile");
expect(content).not.toContain("run: pnpm install\n");
expect(content).not.toContain("--no-frozen-lockfile");
}); });
it("includes pnpm build step", () => { it("includes pnpm build step", () => {
@@ -266,6 +270,13 @@ describe("Test-release workflow (.github/workflows/test-release.yml)", () => {
expect(content).toContain("WINDOWS_CERTIFICATE_BASE64 != ''"); expect(content).toContain("WINDOWS_CERTIFICATE_BASE64 != ''");
}); });
it("uses frozen-lockfile install in every matrix job", () => {
const matches = content.match(/run:\s*pnpm install --frozen-lockfile/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(1);
expect(content).not.toContain("run: pnpm install\n");
expect(content).not.toContain("--no-frozen-lockfile");
});
it("uploads artifacts", () => { it("uploads artifacts", () => {
expect(content).toContain("actions/upload-artifact"); expect(content).toContain("actions/upload-artifact");
}); });

View File

@@ -1160,7 +1160,7 @@ export interface ProjectSettings {
* Only used when pushAfterMerge is true. Default: "origin". */ * Only used when pushAfterMerge is true. Default: "origin". */
pushRemote?: string; pushRemote?: string;
/** Shell command to run inside each new worktree immediately after creation. /** Shell command to run inside each new worktree immediately after creation.
* Useful for project-specific setup (e.g. `pnpm install`, `cp .env.local .env`). */ * Useful for project-specific setup (e.g. `pnpm install --frozen-lockfile`, `cp .env.local .env`). */
worktreeInitCommand?: string; worktreeInitCommand?: string;
/** Custom test command for the project (e.g. "pnpm test") */ /** Custom test command for the project (e.g. "pnpm test") */
testCommand?: string; testCommand?: string;

View File

@@ -2000,7 +2000,7 @@ export function SettingsModal({
<input <input
id="worktreeInitCommand" id="worktreeInitCommand"
type="text" type="text"
placeholder="pnpm install" placeholder="pnpm install --frozen-lockfile"
value={form.worktreeInitCommand || ""} value={form.worktreeInitCommand || ""}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value })) setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))

View File

@@ -2638,7 +2638,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
expect(screen.getByText("Node Sync", { selector: "h4" })).toBeTruthy(); expect(screen.getByText("Node Sync", { selector: "h4" })).toBeTruthy();
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy(); expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
}); });
@@ -2652,7 +2652,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
expect(screen.queryByLabelText("Sync interval")).toBeNull(); expect(screen.queryByLabelText("Sync interval")).toBeNull();
expect(screen.queryByLabelText("Conflict resolution")).toBeNull(); expect(screen.queryByLabelText("Conflict resolution")).toBeNull();
}); });
@@ -2677,7 +2677,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
const checkbox = screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement; const checkbox = screen.getByLabelText("Enable automatic settings sync") as HTMLInputElement;
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
@@ -2695,7 +2695,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
const select = screen.getByLabelText("Sync interval") as HTMLSelectElement; const select = screen.getByLabelText("Sync interval") as HTMLSelectElement;
expect(select.value).toBe("900000"); expect(select.value).toBe("900000");
@@ -2713,7 +2713,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
const select = screen.getByLabelText("Conflict resolution") as HTMLSelectElement; const select = screen.getByLabelText("Conflict resolution") as HTMLSelectElement;
expect(select.value).toBe("last-write-wins"); expect(select.value).toBe("last-write-wins");
@@ -2725,7 +2725,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Node Sync")); fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
const checkbox = screen.getByLabelText("Enable automatic settings sync"); const checkbox = screen.getByLabelText("Enable automatic settings sync");
fireEvent.click(checkbox); fireEvent.click(checkbox);

View File

@@ -432,7 +432,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
pollIntervalMs: 15000, pollIntervalMs: 15000,
groupOverlappingFiles: false, groupOverlappingFiles: false,
autoMerge: false, autoMerge: false,
worktreeInitCommand: "pnpm install", worktreeInitCommand: "pnpm install --frozen-lockfile",
}); });
const executor = new TaskExecutor(store, "/tmp/test"); const executor = new TaskExecutor(store, "/tmp/test");
@@ -440,7 +440,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
// execSync is called for worktree creation + init command // execSync is called for worktree creation + init command
const initCall = mockedExecSync.mock.calls.find( const initCall = mockedExecSync.mock.calls.find(
(call) => call[0] === "pnpm install", (call) => call[0] === "pnpm install --frozen-lockfile",
); );
expect(initCall).toBeDefined(); expect(initCall).toBeDefined();
expect(initCall![1]).toMatchObject({ expect(initCall![1]).toMatchObject({
@@ -452,7 +452,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
"FN-010", "FN-010",
expect.stringMatching(/^\[timing\] Worktree init command completed in \d+ms$/), expect.stringMatching(/^\[timing\] Worktree init command completed in \d+ms$/),
"pnpm install", "pnpm install --frozen-lockfile",
expect.objectContaining({ agentId: "executor" }), expect.objectContaining({ agentId: "executor" }),
); );
}); });
@@ -464,7 +464,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
const executor = new TaskExecutor(store, "/tmp/test"); const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask()); await executor.execute(makeTask());
// Only worktree creation calls to execSync, no "pnpm install" etc. // Only worktree creation calls to execSync, no "pnpm install --frozen-lockfile" etc.
const initCall = mockedExecSync.mock.calls.find( const initCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && !call[0].startsWith("git"), (call) => typeof call[0] === "string" && !call[0].startsWith("git"),
); );
@@ -524,7 +524,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
pollIntervalMs: 15000, pollIntervalMs: 15000,
groupOverlappingFiles: false, groupOverlappingFiles: false,
autoMerge: false, autoMerge: false,
worktreeInitCommand: "pnpm install", worktreeInitCommand: "pnpm install --frozen-lockfile",
}); });
// Worktree already exists (resume) // Worktree already exists (resume)
@@ -2019,15 +2019,15 @@ describe("TaskExecutor worktree pool integration", () => {
groupOverlappingFiles: false, groupOverlappingFiles: false,
autoMerge: false, autoMerge: false,
recycleWorktrees: true, recycleWorktrees: true,
worktreeInitCommand: "pnpm install", worktreeInitCommand: "pnpm install --frozen-lockfile",
}); });
const executor = new TaskExecutor(store, "/tmp/test", { pool }); const executor = new TaskExecutor(store, "/tmp/test", { pool });
await executor.execute(makeTask()); await executor.execute(makeTask());
// "pnpm install" should NOT have been called (pooled worktree has warm cache) // "pnpm install --frozen-lockfile" should NOT have been called (pooled worktree has warm cache)
const initCalls = mockedExecSync.mock.calls.filter( const initCalls = mockedExecSync.mock.calls.filter(
(c) => c[0] === "pnpm install", (c) => c[0] === "pnpm install --frozen-lockfile",
); );
expect(initCalls).toHaveLength(0); expect(initCalls).toHaveLength(0);
}); });

View File

@@ -1320,11 +1320,16 @@ export class TaskExecutor {
} }
// Run worktree init command for fresh worktrees (skip for pooled — caches are warm). // Run worktree init command for fresh worktrees (skip for pooled — caches are warm).
// The init command must leave the worktree in a state where workspace // The init command should deterministically install the full dependency
// packages resolve at runtime (e.g. `pnpm install && pnpm build`). If // graph required by test/typecheck/build commands (for pnpm workspaces,
// it doesn't, the first command the executor runs will usually fail // prefer `pnpm install --frozen-lockfile`) so transitive modules and
// with "@fusion/core entry not found" because monorepo exports point // declarations like @vitest/runner, loupe, debug, @types/express, and
// to dist/. 5-minute timeout accommodates install + build together. // node-pty are present after bootstrap.
//
// NOTE: This is distinct from the separate workspace-export failure
// class where internal packages fail to resolve because exports point
// to missing dist/* outputs (e.g. "@fusion/core entry not found").
// 5-minute timeout accommodates larger dependency installs.
if (settings.worktreeInitCommand) { if (settings.worktreeInitCommand) {
const initStartedAt = Date.now(); const initStartedAt = Date.now();
try { try {

View File

@@ -366,7 +366,7 @@ describe("In-progress task resume after restart", () => {
const store = createMockStore(); const store = createMockStore();
store.getSettings.mockResolvedValue({ store.getSettings.mockResolvedValue({
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
worktreeInitCommand: "pnpm install", worktreeInitCommand: "pnpm install --frozen-lockfile",
}); });
const task = makeTask("FN-030", "in-progress"); const task = makeTask("FN-030", "in-progress");
store.listTasks.mockResolvedValue([task]); store.listTasks.mockResolvedValue([task]);
@@ -384,7 +384,7 @@ describe("In-progress task resume after restart", () => {
// No init command calls // No init command calls
const initCalls = mockedExecSync.mock.calls.filter( const initCalls = mockedExecSync.mock.calls.filter(
(call) => call[0] === "pnpm install", (call) => call[0] === "pnpm install --frozen-lockfile",
); );
expect(initCalls).toHaveLength(0); expect(initCalls).toHaveLength(0);
}); });