Skip to content

Commit 76d3002

Browse files
feat: add macOS version validation for workerd compatibility (v3 backport) (#10216)
* feat: add macOS version validation for workerd compatibility Add macOS version validation to ensure compatibility with the Cloudflare Workers runtime (workerd). - Add checkMacOSVersion function to @cloudflare/cli package - Miniflare constructor fails hard on unsupported macOS versions - Wrangler warns but continues on unsupported macOS versions - C3 fails hard on unsupported macOS versions - Minimum required version: macOS 13.5.0 - Validation is skipped on non-Darwin platforms and in CI environments - Comprehensive test coverage with 22 test cases This ensures users are informed about macOS compatibility requirements and can take appropriate action (upgrade macOS or use DevContainer setup). Co-Authored-By: [email protected] <[email protected]> * fix: bundle @cloudflare/cli in miniflare build The miniflare build was failing because @cloudflare/cli was marked as external, preventing the checkMacOSVersion function from being resolved at build time. This change allows @cloudflare/cli to be bundled into miniflare, fixing the 'Could not resolve @cloudflare/cli' build error. This is necessary for the macOS version validation feature that calls checkMacOSVersion in the Miniflare constructor. Co-Authored-By: [email protected] <[email protected]> * add changeset for macOS validation feature Co-Authored-By: [email protected] <[email protected]> * fix: add @cloudflare/cli dependency to miniflare and CI env var to turbo.json - Add @cloudflare/cli as devDependency in miniflare package.json to resolve build error - Add CI environment variable to turbo.json globalEnv to fix linting error - This allows miniflare to import checkMacOSVersion from @cloudflare/cli package Co-Authored-By: [email protected] <[email protected]> * fix: address PR feedback - remove C3 from backport and build.mjs filter - Remove packages/create-cloudflare/src/cli.ts (C3 not backported to v3-maintenance) - Remove build.mjs filter for @cloudflare/cli since it's now a devDependency - Remove create-cloudflare entry from changeset Addresses GitHub comments from @petebacondarwin on PR #10216 Co-Authored-By: [email protected] <[email protected]> * fix: remove @cloudflare/cli from changeset for v3-maintenance compatibility The CLI package is private and lacks a deploy script, making it non-deployable according to changeset validation logic. Only include miniflare and wrangler packages that are actually deployed in v3-maintenance. Fixes CI 'Checks' job failure on backport PR #10216 Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent 989e17e commit 76d3002

File tree

9 files changed

+321
-0
lines changed

9 files changed

+321
-0
lines changed

.changeset/silly-things-walk.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"miniflare": patch
3+
"wrangler": patch
4+
---
5+
6+
Add macOS version validation to prevent EPIPE errors on unsupported macOS versions (below 13.5). Miniflare and C3 fail hard while Wrangler shows warnings but continues execution.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import os from "node:os";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { checkMacOSVersion } from "../check-macos-version";
4+
5+
vi.mock("node:os");
6+
7+
const mockOs = vi.mocked(os);
8+
9+
describe("checkMacOSVersion", () => {
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
vi.unstubAllEnvs();
13+
});
14+
15+
it("should not throw on non-macOS platforms", () => {
16+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
17+
18+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
19+
});
20+
21+
it("should not throw on macOS 13.5.0", () => {
22+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
23+
mockOs.release.mockReturnValue("22.6.0");
24+
25+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
26+
});
27+
28+
it("should not throw on macOS 14.0.0", () => {
29+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
30+
mockOs.release.mockReturnValue("23.0.0");
31+
32+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
33+
});
34+
35+
it("should not throw on macOS 13.6.0", () => {
36+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
37+
mockOs.release.mockReturnValue("22.7.0");
38+
39+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
40+
});
41+
42+
it("should throw error on macOS 12.7.6", () => {
43+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
44+
vi.stubEnv("CI", "");
45+
mockOs.release.mockReturnValue("21.6.0");
46+
47+
expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow(
48+
"Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (12.6.0)"
49+
);
50+
});
51+
52+
it("should throw error on macOS 13.4.0", () => {
53+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
54+
vi.stubEnv("CI", "");
55+
mockOs.release.mockReturnValue("22.4.0");
56+
57+
expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow(
58+
"Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (13.4.0)"
59+
);
60+
});
61+
62+
it("should handle invalid Darwin version format gracefully", () => {
63+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
64+
mockOs.release.mockReturnValue("invalid-version");
65+
66+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
67+
});
68+
69+
it("should handle very old Darwin versions gracefully", () => {
70+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
71+
mockOs.release.mockReturnValue("19.6.0");
72+
73+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
74+
});
75+
76+
it("should not throw when CI environment variable is set to 'true'", () => {
77+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
78+
vi.stubEnv("CI", "true");
79+
mockOs.release.mockReturnValue("21.6.0");
80+
81+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
82+
});
83+
84+
it("should not throw when CI environment variable is set to '1'", () => {
85+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
86+
vi.stubEnv("CI", "1");
87+
mockOs.release.mockReturnValue("21.6.0");
88+
89+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
90+
});
91+
92+
it("should not throw when CI environment variable is set to 'yes'", () => {
93+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
94+
vi.stubEnv("CI", "yes");
95+
mockOs.release.mockReturnValue("21.6.0");
96+
97+
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
98+
});
99+
});
100+
101+
describe("checkMacOSVersion with shouldThrow=false", () => {
102+
beforeEach(() => {
103+
vi.clearAllMocks();
104+
vi.unstubAllEnvs();
105+
});
106+
107+
it("should not warn on non-macOS platforms", () => {
108+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
109+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
110+
111+
checkMacOSVersion({ shouldThrow: false });
112+
113+
expect(warnSpy).not.toHaveBeenCalled();
114+
});
115+
116+
it("should not warn on macOS 13.5.0", () => {
117+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
118+
mockOs.release.mockReturnValue("22.6.0");
119+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
120+
121+
checkMacOSVersion({ shouldThrow: false });
122+
123+
expect(warnSpy).not.toHaveBeenCalled();
124+
});
125+
126+
it("should warn on macOS 12.7.6", () => {
127+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
128+
vi.stubEnv("CI", "");
129+
mockOs.release.mockReturnValue("21.6.0");
130+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
131+
132+
checkMacOSVersion({ shouldThrow: false });
133+
134+
expect(warnSpy).toHaveBeenCalledWith(
135+
expect.stringContaining(
136+
"⚠️ Warning: Unsupported macOS version detected (12.6.0)"
137+
)
138+
);
139+
});
140+
141+
it("should warn on macOS 13.4.0", () => {
142+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
143+
vi.stubEnv("CI", "");
144+
mockOs.release.mockReturnValue("22.4.0");
145+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
146+
147+
checkMacOSVersion({ shouldThrow: false });
148+
149+
expect(warnSpy).toHaveBeenCalledWith(
150+
expect.stringContaining(
151+
"⚠️ Warning: Unsupported macOS version detected (13.4.0)"
152+
)
153+
);
154+
});
155+
156+
it("should not warn when CI environment variable is set to 'true'", () => {
157+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
158+
vi.stubEnv("CI", "true");
159+
mockOs.release.mockReturnValue("21.6.0");
160+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
161+
162+
checkMacOSVersion({ shouldThrow: false });
163+
164+
expect(warnSpy).not.toHaveBeenCalled();
165+
});
166+
167+
it("should not warn when CI environment variable is set to '1'", () => {
168+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
169+
vi.stubEnv("CI", "1");
170+
mockOs.release.mockReturnValue("21.6.0");
171+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
172+
173+
checkMacOSVersion({ shouldThrow: false });
174+
175+
expect(warnSpy).not.toHaveBeenCalled();
176+
});
177+
178+
it("should not warn when CI environment variable is set to 'yes'", () => {
179+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
180+
vi.stubEnv("CI", "yes");
181+
mockOs.release.mockReturnValue("21.6.0");
182+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
183+
184+
checkMacOSVersion({ shouldThrow: false });
185+
186+
expect(warnSpy).not.toHaveBeenCalled();
187+
});
188+
189+
it("should not warn on invalid Darwin version format", () => {
190+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
191+
mockOs.release.mockReturnValue("invalid-version");
192+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
193+
194+
checkMacOSVersion({ shouldThrow: false });
195+
196+
expect(warnSpy).not.toHaveBeenCalled();
197+
});
198+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import os from "node:os";
2+
3+
/**
4+
* Minimum macOS version required for workerd compatibility.
5+
*/
6+
const MINIMUM_MACOS_VERSION = "13.5.0";
7+
8+
/**
9+
* Checks the current macOS version for workerd compatibility.
10+
*
11+
* This function is a no-op on non-Darwin platforms and in CI environments.
12+
*
13+
* @param options - Configuration object
14+
* @param options.shouldThrow - If true, throws an error on unsupported versions. If false, logs a warning.
15+
*/
16+
export function checkMacOSVersion(options: { shouldThrow: boolean }): void {
17+
if (process.platform !== "darwin") {
18+
return;
19+
}
20+
21+
if (process.env.CI) {
22+
return;
23+
}
24+
25+
const release = os.release();
26+
const macOSVersion = darwinVersionToMacOSVersion(release);
27+
28+
if (macOSVersion && isVersionLessThan(macOSVersion, MINIMUM_MACOS_VERSION)) {
29+
if (options.shouldThrow) {
30+
throw new Error(
31+
`Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (${macOSVersion}). ` +
32+
`The minimum requirement is macOS ${MINIMUM_MACOS_VERSION}+. See https://github.com/cloudflare/workerd?tab=readme-ov-file#running-workerd ` +
33+
`If you cannot upgrade your version of macOS, you could try running in a DevContainer setup with a supported version of Linux (glibc 2.35+ required).`
34+
);
35+
} else {
36+
// eslint-disable-next-line no-console
37+
console.warn(
38+
`⚠️ Warning: Unsupported macOS version detected (${macOSVersion}). ` +
39+
`The Cloudflare Workers runtime may not work correctly on macOS versions below ${MINIMUM_MACOS_VERSION}. ` +
40+
`Consider upgrading to macOS ${MINIMUM_MACOS_VERSION}+ or using a DevContainer setup with a supported version of Linux (glibc 2.35+ required).`
41+
);
42+
}
43+
}
44+
}
45+
46+
/**
47+
* Converts Darwin kernel version to macOS version.
48+
* Darwin 21.x.x = macOS 12.x (Monterey)
49+
* Darwin 22.x.x = macOS 13.x (Ventura)
50+
* Darwin 23.x.x = macOS 14.x (Sonoma)
51+
* etc.
52+
*/
53+
function darwinVersionToMacOSVersion(darwinVersion: string): string | null {
54+
const match = darwinVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
55+
if (!match) {
56+
return null;
57+
}
58+
59+
const major = parseInt(match[1], 10);
60+
61+
if (major >= 20) {
62+
const macOSMajor = major - 9;
63+
const minor = parseInt(match[2], 10);
64+
const patch = parseInt(match[3], 10);
65+
return `${macOSMajor}.${minor}.${patch}`;
66+
}
67+
68+
return null;
69+
}
70+
71+
/**
72+
* Simple semver comparison for major.minor.patch versions.
73+
* Validates that both versions follow the M.m.p format before comparison.
74+
*/
75+
function isVersionLessThan(version1: string, version2: string): boolean {
76+
const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/;
77+
78+
const match1 = version1.match(versionRegex);
79+
const match2 = version2.match(versionRegex);
80+
81+
if (!match1 || !match2) {
82+
throw new Error(
83+
`Invalid version format. Expected M.m.p format, got: ${version1}, ${version2}`
84+
);
85+
}
86+
87+
const [major1, minor1, patch1] = [
88+
parseInt(match1[1], 10),
89+
parseInt(match1[2], 10),
90+
parseInt(match1[3], 10),
91+
];
92+
const [major2, minor2, patch2] = [
93+
parseInt(match2[1], 10),
94+
parseInt(match2[2], 10),
95+
parseInt(match2[3], 10),
96+
];
97+
98+
if (major1 !== major2) {
99+
return major1 < major2;
100+
}
101+
if (minor1 !== minor2) {
102+
return minor1 < minor2;
103+
}
104+
return patch1 < patch2;
105+
}

packages/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,5 @@ export const error = (
245245
);
246246
}
247247
};
248+
249+
export { checkMacOSVersion } from "./check-macos-version";

packages/miniflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
},
5757
"devDependencies": {
5858
"@ava/typescript": "^4.1.0",
59+
"@cloudflare/cli": "workspace:*",
5960
"@cloudflare/kv-asset-handler": "workspace:*",
6061
"@cloudflare/workers-shared": "workspace:*",
6162
"@cloudflare/workers-types": "^4.20250408.0",

packages/miniflare/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Duplex, Transform, Writable } from "stream";
1010
import { ReadableStream } from "stream/web";
1111
import util from "util";
1212
import zlib from "zlib";
13+
import { checkMacOSVersion } from "@cloudflare/cli";
1314
import exitHook from "exit-hook";
1415
import { $ as colors$ } from "kleur/colors";
1516
import stoppable from "stoppable";
@@ -749,6 +750,8 @@ export class Miniflare {
749750
constructor(opts: MiniflareOptions) {
750751
// Split and validate options
751752
const [sharedOpts, workerOpts] = validateOptions(opts);
753+
754+
checkMacOSVersion({ shouldThrow: true });
752755
this.#sharedOpts = sharedOpts;
753756
this.#workerOpts = workerOpts;
754757

packages/wrangler/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os from "node:os";
22
import { setTimeout } from "node:timers/promises";
3+
import { checkMacOSVersion } from "@cloudflare/cli";
34
import chalk from "chalk";
45
import { ProxyAgent, setGlobalDispatcher } from "undici";
56
import makeCLI from "yargs";
@@ -1021,6 +1022,7 @@ export function createCLIParser(argv: string[]) {
10211022
export async function main(argv: string[]): Promise<void> {
10221023
setupSentry();
10231024

1025+
checkMacOSVersion({ shouldThrow: false });
10241026
const startTime = Date.now();
10251027
const wrangler = createCLIParser(argv);
10261028
let command: string | undefined;

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

turbo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"signature": true
55
},
66
"globalEnv": [
7+
"CI",
78
"CI_OS",
89
"NODE_VERSION",
910
"VITEST",

0 commit comments

Comments
 (0)