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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/test-release.yml
vendored
2
.github/workflows/test-release.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/version.yml
vendored
2
.github/workflows/version.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user