fix: run publish checks in background (#6476)

## Summary
- Start dynamic and static publish diagnostics in the background,
without waiting for them before sending the publish request.
- Error findings, failed or hanging checks, and failures while reporting
them cannot block or delay publishing.
- Keep the standalone Validate action's reporting behavior unchanged.
- If diagnostics fail, the dialog explains that the publish request was
sent without waiting for them.
- The publish operation remains authoritative for authorization and
publish-job failures.

## Verification
- `pnpm --filter @webstudio-is/builder test --
app/builder/features/publish/publish-preflight.test.ts
app/builder/features/publish/publish-error.test.ts` (8 tests passed)
- `pnpm --filter @webstudio-is/builder typecheck`
- `pnpm exec oxfmt` on changed files
- `git diff --check`
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-09-29 20:03:43 +02:00
1 parent a6a9049399
commit e700a24210
4 files changed
+147 -40

No files matched your search

@@ -8,7 +8,7 @@ import {
import { formatAssetFolderPath } from "~/builder/shared/asset-manager/asset-folder-utils";
export const publishValidationTimeoutMessage =
"Publish validation timed out. Publishing was not started. Please try again.";
"Publish validation timed out. Try again to view the diagnostics.";
export const getPublishValidationErrorMessage = (
error: unknown,
@@ -0,0 +1,76 @@
import { describe, expect, test, vi } from "vitest";
import { runPublishAfterBestEffortChecks } from "./publish-preflight";
describe("runPublishAfterBestEffortChecks", () => {
test.each([
["error findings", async () => ({ passed: false, findings: ["error"] })],
[
"a failed check",
async () => {
throw new Error("check failed");
},
],
])("publishes after %s", async (_label, checks) => {
const publish = vi.fn().mockResolvedValue("publish started");
const result = await runPublishAfterBestEffortChecks({
checks,
onCheckFailure: vi.fn(),
publish,
});
expect(result).toBe("publish started");
expect(publish).toHaveBeenCalledOnce();
});
test("starts publishing without waiting for checks to finish", async () => {
const order: string[] = [];
const checks = vi.fn(
() =>
new Promise<void>(() => {
order.push("checks started");
})
);
const publish = vi.fn(async () => {
order.push("publish started");
return "publish started";
});
await expect(
runPublishAfterBestEffortChecks({
checks,
onCheckFailure: vi.fn(),
publish,
})
).resolves.toBe("publish started");
expect(order).toEqual(["publish started", "checks started"]);
});
test("publishes even if reporting a check failure throws", async () => {
const publish = vi.fn().mockResolvedValue("publish started");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
throw new Error("logging failed");
});
let finishReporting!: () => void;
const reportFinished = new Promise<void>((resolve) => {
finishReporting = resolve;
});
const result = await runPublishAfterBestEffortChecks({
checks: async () => {
throw new Error("check failed");
},
onCheckFailure: () => {
finishReporting();
throw new Error("report failed");
},
publish,
});
await reportFinished;
expect(result).toBe("publish started");
expect(publish).toHaveBeenCalledOnce();
consoleError.mockRestore();
});
});
@@ -0,0 +1,22 @@
export const runPublishAfterBestEffortChecks = <T>({
checks,
onCheckFailure,
publish,
}: {
checks: () => Promise<unknown>;
onCheckFailure: (error: unknown) => void;
publish: () => Promise<T>;
}): Promise<T> => {
const reportFailure = (error: unknown) => {
try {
onCheckFailure(error);
} catch (reportError) {
try {
console.error("Could not report publish diagnostics", reportError);
} catch {}
}
};
void Promise.resolve().then(checks).catch(reportFailure);
return publish();
};
@@ -129,6 +129,7 @@ import {
} from "./publish-actions";
import { flushExternalContentProject } from "~/shared/external-content-roots";
import { getPublishValidationErrorMessage } from "./publish-error";
import { runPublishAfterBestEffortChecks } from "./publish-preflight";
const PrePublishAuditMessage = ({
finding,
@@ -238,14 +239,7 @@ const getPrePublishAuditFindings = (): PublishValidationFinding[] => {
});
};
const runPublishValidation = async (
projectId: Project["id"],
checkPermission = false
) => {
if (checkPermission) {
await nativeClient.build.checkProjectBuildPermission.query({ projectId });
}
await flushExternalContentProject({ projectId });
const runPublishDiagnostics = async (projectId: Project["id"]) => {
const auditFindings = getPrePublishAuditFindings();
const diagnostics =
await nativeClient.build.contentDatabasePublishDiagnostics.query({
@@ -269,14 +263,29 @@ const runPublishValidation = async (
};
};
const runPublishValidation = async (
projectId: Project["id"],
checkPermission = false
) => {
if (checkPermission) {
await nativeClient.build.checkProjectBuildPermission.query({ projectId });
}
await flushExternalContentProject({ projectId });
return runPublishDiagnostics(projectId);
};
const reportPublishValidationFailure = (
error: unknown,
setFindings: (findings: PublishValidationFinding[]) => void
setFindings: (findings: PublishValidationFinding[]) => void,
publishingContinues = false
) => {
const message = getPublishValidationErrorMessage(error, {
const diagnosticMessage = getPublishValidationErrorMessage(error, {
assets: $assets.get(),
assetFolders: $assetFolders.get(),
});
const message = publishingContinues
? `${diagnosticMessage}\n\nThe publish request was sent without waiting for these diagnostics.`
: diagnosticMessage;
if ($publishDialog.get() === "none") {
toast.error(message);
return;
@@ -298,7 +307,9 @@ const reportPublishValidationFailure = (
setFindings([
{
severity: "error",
title: "Unable to complete publish validation",
title: publishingContinues
? "Publish checks couldn’t complete"
: "Unable to complete publish validation",
details,
reportText: `ERROR: Unable to complete publish validation\n${message}`,
},
@@ -810,18 +821,15 @@ const Publish = ({
startTransition(async () => {
setIsPublishing(true);
try {
const checks = await runPublishValidation(project.id, true);
setPublishFindings(checks.findings);
if (checks.passed === false) {
return;
}
} catch (error) {
reportPublishValidationFailure(error, setPublishFindings);
return;
}
await publish(domains);
await runPublishAfterBestEffortChecks({
checks: async () => {
const checks = await runPublishDiagnostics(project.id);
setPublishFindings(checks.findings);
},
onCheckFailure: (error) =>
reportPublishValidationFailure(error, setPublishFindings, true),
publish: () => publish(domains),
});
});
};
@@ -947,22 +955,23 @@ const PublishStatic = ({
startTransition(async () => {
try {
setIsPendingOptimistic(true);
let checks: Awaited<ReturnType<typeof runPublishValidation>>;
try {
checks = await runPublishValidation(projectId);
} catch (error) {
reportPublishValidationFailure(error, setPublishFindings);
return;
}
setPublishFindings(checks.findings);
if (checks.passed === false) {
return;
}
const result = await nativeClient.domain.publish.mutate({
projectId,
destination: "static",
templates: [...templates],
const result = await runPublishAfterBestEffortChecks({
checks: async () => {
const checks = await runPublishDiagnostics(projectId);
setPublishFindings(checks.findings);
},
onCheckFailure: (error) =>
reportPublishValidationFailure(
error,
setPublishFindings,
true
),
publish: () =>
nativeClient.domain.publish.mutate({
projectId,
destination: "static",
templates: [...templates],
}),
});
if (result.success === false) {