chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { it } from "vitest";
|
||||
import { getNextMiniflareVersion } from "../../../.github/changeset-version";
|
||||
|
||||
// prettier-ignore
|
||||
const miniflareVersionTestCases = [
|
||||
// workerd, mf previous, bump mf after, mf corrected
|
||||
["1.20231001.0", "3.20231001.0", /* patch */ "3.20231001.1", "3.20231001.1"],
|
||||
["1.20231001.0", "3.20231001.0", /* minor */ "3.20231002.0", "3.20231001.1"],
|
||||
["1.20231002.0", "3.20231001.0", /* minor */ "3.20231002.0", "3.20231002.0"],
|
||||
["1.20231001.0", "3.20231001.2", /* minor */ "3.20231002.0", "3.20231001.3"],
|
||||
["1.20231001.0", "3.20231001.0", /* major */ "4.0.0", "4.20231001.0"],
|
||||
["1.20231008.0", "3.20231001.0", /* patch */ "3.20231001.1", "3.20231008.0"],
|
||||
["1.20231008.0", "3.20231001.0", /* minor */ "3.20231002.0", "3.20231008.0"],
|
||||
["1.20231008.0", "3.20231001.0", /* major */ "4.0.0", "4.20231008.0"],
|
||||
];
|
||||
|
||||
for (const [
|
||||
workerdVersion,
|
||||
previousMiniflareVersion,
|
||||
miniflareVersion,
|
||||
correctMiniflareVersion,
|
||||
] of miniflareVersionTestCases) {
|
||||
it(`changeset version ${workerdVersion} ${previousMiniflareVersion} -> ${miniflareVersion} = ${correctMiniflareVersion}`, ({
|
||||
expect,
|
||||
}) => {
|
||||
const actual = getNextMiniflareVersion(
|
||||
workerdVersion,
|
||||
previousMiniflareVersion,
|
||||
miniflareVersion
|
||||
);
|
||||
expect(actual).toEqual(correctMiniflareVersion);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, it, vitest } from "vitest";
|
||||
import {
|
||||
deployNonNpmPackages,
|
||||
deployPackage,
|
||||
findDeployablePackageNames,
|
||||
getUpdatedPackages,
|
||||
} from "../deploy-non-npm-packages";
|
||||
import type { UpdatedPackage } from "../deploy-non-npm-packages";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
vitest.mock("node:child_process", async () => {
|
||||
return {
|
||||
spawnSync: vitest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getUpdatedPackages()", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should default to an empty array", ({ expect }) => {
|
||||
expect(getUpdatedPackages()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse JSON from the PUBLISHED_PACKAGES env var", ({ expect }) => {
|
||||
const expectedPackages = [
|
||||
{ name: "a", version: "1.0.0" },
|
||||
{ name: "b", version: "2.3.4" },
|
||||
];
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: JSON.stringify(expectedPackages),
|
||||
};
|
||||
expect(getUpdatedPackages()).toEqual(expectedPackages);
|
||||
});
|
||||
|
||||
it("should validate the shape of the PUBLISHED_PACKAGES JSON", ({
|
||||
expect,
|
||||
}) => {
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `"bad"`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected PUBLISHED_PACKAGES to be an array but got string.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `["bad"]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 0 in array to be an array but got string.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `[{}]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 0 to have a "name" property of type string but got undefined.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `[{ "name": 123 }]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 0 to have a "name" property of type string but got 123.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `[{ "name": "package" }]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 0 to have a "version" property of type string but got undefined.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `[{ "name": "package", "version": ["bad"] }]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 0 to have a "version" property of type string but got bad.]`
|
||||
);
|
||||
|
||||
process.env = {
|
||||
PUBLISHED_PACKAGES: `[{ "name": "package", "version": "1.2.3" }, {}]`,
|
||||
};
|
||||
expect(() => getUpdatedPackages()).toThrowErrorMatchingInlineSnapshot(
|
||||
`[AssertionError: Expected item 1 to have a "name" property of type string but got undefined.]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDeployablePackageNames()", () => {
|
||||
it("should return all the private packages which contain deploy scripts", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(findDeployablePackageNames()).toMatchInlineSnapshot(`
|
||||
Set {
|
||||
"@cloudflare/chrome-devtools-patches",
|
||||
"@cloudflare/devprod-status-bot",
|
||||
"@cloudflare/edge-preview-authenticated-proxy",
|
||||
"@cloudflare/format-errors",
|
||||
"@cloudflare/playground-preview-worker",
|
||||
"@cloudflare/quick-edit",
|
||||
"@cloudflare/turbo-r2-archive",
|
||||
"@cloudflare/workers-playground",
|
||||
"@cloudflare/workers-shared",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployPackage", () => {
|
||||
it("should run `pnpm deploy` for the given package via `spawnSync`", ({
|
||||
expect,
|
||||
}) => {
|
||||
deployPackage("foo", new Map());
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
"pnpm",
|
||||
["-F", "foo", "run", "deploy"],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore failures in `spawnSync`", ({ expect }) => {
|
||||
(spawnSync as Mock).mockImplementationOnce(() => {
|
||||
throw new Error("Bad deployment");
|
||||
});
|
||||
const logs: string[] = [];
|
||||
vitest.spyOn(console, "error").mockImplementation((v) => logs.push(v));
|
||||
deployPackage("foo", new Map());
|
||||
expect(logs[0]).toMatchInlineSnapshot(`"::error::Failed to deploy "foo"."`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployNonNpmPackages()", () => {
|
||||
it("should run `pnpm deploy` on each deployable updated package", ({
|
||||
expect,
|
||||
}) => {
|
||||
const updatedPackages: UpdatedPackage[] = [
|
||||
{ name: "a", version: "1.0.0" },
|
||||
{ name: "b", version: "2.0.4" },
|
||||
{ name: "c", version: "3.0.0" },
|
||||
{ name: "d", version: "4.0.0" },
|
||||
];
|
||||
const deployablePackageNames = new Set(["a", "c", "e"]);
|
||||
vitest.spyOn(console, "log").mockImplementation(() => {});
|
||||
deployNonNpmPackages(updatedPackages, deployablePackageNames);
|
||||
expect((console.log as Mock).mock.calls.flat()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Checking for non-npm packages to deploy...",
|
||||
"Package "a@1.0.0": deploying...",
|
||||
"Package "b@2.0.4": already deployed via npm.",
|
||||
"Package "c@3.0.0": deploying...",
|
||||
"Package "d@4.0.0": already deployed via npm.",
|
||||
"Deployed 2 non-npm packages.",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should run display an informative message if no packages to deploy", ({
|
||||
expect,
|
||||
}) => {
|
||||
const updatedPackages: UpdatedPackage[] = [];
|
||||
const deployablePackageNames = new Set(["a", "c", "e"]);
|
||||
vitest.spyOn(console, "log").mockImplementation(() => {});
|
||||
deployNonNpmPackages(updatedPackages, deployablePackageNames);
|
||||
expect((console.log as Mock).mock.calls.flat()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Checking for non-npm packages to deploy...",
|
||||
"No non-npm packages to deploy.",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import dedent from "ts-dedent";
|
||||
import { afterEach, beforeEach, describe, it } from "vitest";
|
||||
import {
|
||||
findPackages,
|
||||
readChangesets,
|
||||
validateChangesets,
|
||||
} from "../validate-changesets";
|
||||
import type { PackageJSON } from "../validate-changesets";
|
||||
|
||||
describe("findPackageNames()", () => {
|
||||
it("should return all the private packages which contain deploy scripts", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(new Set(findPackages().keys())).toEqual(
|
||||
new Set([
|
||||
"@cloudflare/autoconfig",
|
||||
"@cloudflare/chrome-devtools-patches",
|
||||
"@cloudflare/cli-shared-helpers",
|
||||
"@cloudflare/codemod",
|
||||
"@cloudflare/containers-shared",
|
||||
"@cloudflare/deploy-helpers",
|
||||
"@cloudflare/devprod-status-bot",
|
||||
"@cloudflare/edge-preview-authenticated-proxy",
|
||||
"@cloudflare/lint-config-shared",
|
||||
"@cloudflare/format-errors",
|
||||
"@cloudflare/kv-asset-handler",
|
||||
"@cloudflare/local-explorer-ui",
|
||||
"@cloudflare/pages-shared",
|
||||
"@cloudflare/playground-preview-worker",
|
||||
"@cloudflare/quick-edit",
|
||||
"@cloudflare/turbo-r2-archive",
|
||||
"@cloudflare/unenv-preset",
|
||||
"@cloudflare/vite-plugin",
|
||||
"@cloudflare/vitest-pool-workers",
|
||||
"@cloudflare/workers-auth",
|
||||
"@cloudflare/workers-editor-shared",
|
||||
"@cloudflare/workers-playground",
|
||||
"@cloudflare/workers-shared",
|
||||
"@cloudflare/workers-utils",
|
||||
"@cloudflare/workflows-shared",
|
||||
"create-cloudflare",
|
||||
"miniflare",
|
||||
"solarflare-theme",
|
||||
"wrangler",
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readChangesets()", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use realpath because the temporary path can point to a symlink rather than the actual path.
|
||||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "tools-tests")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tmpDir)) {
|
||||
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- test cleanup
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should load files from the changeset directory that look like changesets", ({
|
||||
expect,
|
||||
}) => {
|
||||
writeFileSync(resolve(tmpDir, "README.md"), "Some text");
|
||||
writeFileSync(resolve(tmpDir, ".hidden.md"), "Some text");
|
||||
writeFileSync(resolve(tmpDir, "change-set-one.md"), "Some text");
|
||||
writeFileSync(resolve(tmpDir, "change-set-two.md"), "Some text");
|
||||
writeFileSync(resolve(tmpDir, "change-set-two.md"), "Some text");
|
||||
writeFileSync(resolve(tmpDir, "config.json"), "Some text");
|
||||
|
||||
const changesets = readChangesets(tmpDir);
|
||||
expect(changesets).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"contents": "Some text",
|
||||
"file": "change-set-one.md",
|
||||
},
|
||||
{
|
||||
"contents": "Some text",
|
||||
"file": "change-set-two.md",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateChangesets()", () => {
|
||||
it("should report errors for any invalid changesets", ({ expect }) => {
|
||||
const errors = validateChangesets(
|
||||
new Map<string, PackageJSON>([
|
||||
["package-a", { name: "package-a" }],
|
||||
["package-b", { name: "package-b" }],
|
||||
["package-c", { name: "package-c" }],
|
||||
]),
|
||||
[
|
||||
{
|
||||
file: "valid-one.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-a": patch
|
||||
---
|
||||
|
||||
refactor: test`,
|
||||
},
|
||||
{
|
||||
file: "valid-two.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-b": minor
|
||||
---
|
||||
|
||||
feature: test`,
|
||||
},
|
||||
{
|
||||
file: "valid-three.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-c": minor
|
||||
---
|
||||
|
||||
chore: test`,
|
||||
},
|
||||
{
|
||||
file: "valid-three.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-c": minor
|
||||
---
|
||||
|
||||
fix: test`,
|
||||
},
|
||||
{ file: "invalid-frontmatter.md", contents: "" },
|
||||
{
|
||||
file: "invalid-package.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-invalid": minor
|
||||
---
|
||||
|
||||
feat: test`,
|
||||
},
|
||||
{
|
||||
file: "invalid-type.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-a": foo
|
||||
---
|
||||
|
||||
docs: test`,
|
||||
},
|
||||
]
|
||||
);
|
||||
expect(errors).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Error: could not parse changeset - invalid frontmatter: at file "invalid-frontmatter.md"",
|
||||
"Unknown package name "package-invalid" in changeset at "invalid-package.md".",
|
||||
"Invalid type "foo" for package "package-a" in changeset at "invalid-type.md".",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should allow major bumps for private packages", ({ expect }) => {
|
||||
const errors = validateChangesets(
|
||||
new Map<string, PackageJSON>([
|
||||
["package-b", { name: "package-b", private: true }],
|
||||
]),
|
||||
[
|
||||
{
|
||||
file: "major-private.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-b": major
|
||||
---
|
||||
|
||||
breaking change for private pkg!`,
|
||||
},
|
||||
]
|
||||
);
|
||||
expect(errors).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
it("should report errors for major bump changesets", ({ expect }) => {
|
||||
const errors = validateChangesets(
|
||||
new Map<string, PackageJSON>([
|
||||
["package-a", { name: "package-a" }],
|
||||
["package-b", { name: "package-b" }],
|
||||
["package-c", { name: "package-c" }],
|
||||
]),
|
||||
[
|
||||
{
|
||||
file: "patch-one.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-a": patch
|
||||
---
|
||||
refactor: test`,
|
||||
},
|
||||
{
|
||||
file: "minor-two.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-b": minor
|
||||
---
|
||||
|
||||
feature: test`,
|
||||
},
|
||||
{
|
||||
file: "major-three.md",
|
||||
contents: dedent`
|
||||
---
|
||||
"package-c": major
|
||||
---
|
||||
|
||||
breaking change!`,
|
||||
},
|
||||
]
|
||||
);
|
||||
expect(errors).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Major version bumps are not allowed for package "package-c" in changeset at "major-three.md".",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
extractBareImports,
|
||||
getAllDependencies,
|
||||
getEntryPointPaths,
|
||||
getNonWorkspaceDependencies,
|
||||
getPackageNameFromSpecifier,
|
||||
getPublicPackages,
|
||||
isBareSpecifier,
|
||||
validateDistImports,
|
||||
validatePackageDependencies,
|
||||
} from "../validate-package-dependencies";
|
||||
|
||||
describe("getAllDependencies()", () => {
|
||||
it("should return empty array for undefined", ({ expect }) => {
|
||||
expect(getAllDependencies(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty object", ({ expect }) => {
|
||||
expect(getAllDependencies({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all dependency names", ({ expect }) => {
|
||||
expect(
|
||||
getAllDependencies({
|
||||
foo: "1.0.0",
|
||||
bar: "workspace:*",
|
||||
baz: "^2.0.0",
|
||||
})
|
||||
).toEqual(["foo", "bar", "baz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNonWorkspaceDependencies()", () => {
|
||||
it("should return empty array for undefined", ({ expect }) => {
|
||||
expect(getNonWorkspaceDependencies(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty object", ({ expect }) => {
|
||||
expect(getNonWorkspaceDependencies({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter out workspace dependencies", ({ expect }) => {
|
||||
expect(
|
||||
getNonWorkspaceDependencies({
|
||||
foo: "1.0.0",
|
||||
bar: "workspace:*",
|
||||
baz: "workspace:^",
|
||||
qux: "^2.0.0",
|
||||
})
|
||||
).toEqual(["foo", "qux"]);
|
||||
});
|
||||
|
||||
it("should return all deps if none are workspace deps", ({ expect }) => {
|
||||
expect(
|
||||
getNonWorkspaceDependencies({
|
||||
foo: "1.0.0",
|
||||
bar: "^2.0.0",
|
||||
})
|
||||
).toEqual(["foo", "bar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePackageDependencies()", () => {
|
||||
it("should return no errors for package with no dependencies", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{ name: "test-package" },
|
||||
null
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return no errors for package with only workspace dependencies", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
"@cloudflare/foo": "workspace:*",
|
||||
"@cloudflare/bar": "workspace:^",
|
||||
},
|
||||
},
|
||||
null
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return error when package has non-workspace deps but no allowlist", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
zod: "^3.0.0",
|
||||
},
|
||||
},
|
||||
null
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('Package "test-package"');
|
||||
expect(errors[0]).toContain("2 non-workspace dependencies");
|
||||
expect(errors[0]).toContain("no scripts/deps.ts file");
|
||||
expect(errors[0]).toContain("lodash, zod");
|
||||
});
|
||||
|
||||
it("should return no errors when all deps are in allowlist", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
zod: "^3.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "zod"]
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return error for dependency not in allowlist", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
zod: "^3.0.0",
|
||||
"new-dep": "^1.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "zod"]
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('Package "test-package"');
|
||||
expect(errors[0]).toContain('"new-dep"');
|
||||
expect(errors[0]).toContain("not listed in EXTERNAL_DEPENDENCIES");
|
||||
});
|
||||
|
||||
it("should return error for stale allowlist entry", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "removed-dep"]
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('Package "test-package"');
|
||||
expect(errors[0]).toContain('"removed-dep"');
|
||||
expect(errors[0]).toContain("not in dependencies or peerDependencies");
|
||||
});
|
||||
|
||||
it("should allow workspace deps in EXTERNAL_DEPENDENCIES without error", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
miniflare: "workspace:*",
|
||||
},
|
||||
},
|
||||
["lodash", "miniflare"]
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should check peerDependencies for stale allowlist entries", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
},
|
||||
peerDependencies: {
|
||||
vite: "^5.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "vite"]
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return multiple errors for multiple issues", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
"undeclared-dep": "^1.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "stale-dep"]
|
||||
);
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0]).toContain('"undeclared-dep"');
|
||||
expect(errors[1]).toContain('"stale-dep"');
|
||||
});
|
||||
|
||||
it("should ignore devDependencies completely", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
devDependencies: {
|
||||
vitest: "^1.0.0",
|
||||
typescript: "^5.0.0",
|
||||
esbuild: "^0.20.0",
|
||||
},
|
||||
},
|
||||
null // No allowlist needed since devDependencies are ignored
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should ignore devDependencies when validating against allowlist", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
// These should NOT trigger "not in allowlist" errors
|
||||
vitest: "^1.0.0",
|
||||
typescript: "^5.0.0",
|
||||
"some-dev-tool": "^1.0.0",
|
||||
},
|
||||
},
|
||||
["lodash"] // Only lodash in allowlist, devDependencies should be ignored
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not require devDependencies to be in allowlist", ({ expect }) => {
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
zod: "^3.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
// Many devDependencies that are NOT in the allowlist
|
||||
vitest: "^1.0.0",
|
||||
typescript: "^5.0.0",
|
||||
esbuild: "^0.20.0",
|
||||
prettier: "^3.0.0",
|
||||
eslint: "^8.0.0",
|
||||
},
|
||||
},
|
||||
["lodash", "zod"] // Only runtime deps in allowlist
|
||||
);
|
||||
// Should pass - devDependencies don't need to be in allowlist
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not count devDependencies as stale allowlist entries", ({
|
||||
expect,
|
||||
}) => {
|
||||
// If a package has something in devDependencies AND in the allowlist,
|
||||
// it should be flagged as stale (since devDeps are bundled, not external)
|
||||
const errors = validatePackageDependencies(
|
||||
"test-package",
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
lodash: "^4.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
// esbuild is in devDependencies (will be bundled)
|
||||
esbuild: "^0.20.0",
|
||||
},
|
||||
},
|
||||
["lodash", "esbuild"] // esbuild in allowlist but only in devDeps = stale
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"esbuild"');
|
||||
expect(errors[0]).toContain("not in dependencies or peerDependencies");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPackageNameFromSpecifier()", () => {
|
||||
it("should return the package name from a bare specifier", ({ expect }) => {
|
||||
expect(getPackageNameFromSpecifier("lodash")).toBe("lodash");
|
||||
});
|
||||
|
||||
it("should strip subpath imports", ({ expect }) => {
|
||||
expect(getPackageNameFromSpecifier("lodash/fp")).toBe("lodash");
|
||||
expect(getPackageNameFromSpecifier("semver/functions/satisfies.js")).toBe(
|
||||
"semver"
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve scoped package names", ({ expect }) => {
|
||||
expect(getPackageNameFromSpecifier("@cloudflare/workers-utils")).toBe(
|
||||
"@cloudflare/workers-utils"
|
||||
);
|
||||
expect(
|
||||
getPackageNameFromSpecifier("@cloudflare/workers-utils/test-helpers")
|
||||
).toBe("@cloudflare/workers-utils");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBareSpecifier()", () => {
|
||||
it("should accept bare package names", ({ expect }) => {
|
||||
expect(isBareSpecifier("lodash")).toBe(true);
|
||||
expect(isBareSpecifier("@cloudflare/workers-utils")).toBe(true);
|
||||
expect(isBareSpecifier("vitest/runtime")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject relative paths", ({ expect }) => {
|
||||
expect(isBareSpecifier("./foo")).toBe(false);
|
||||
expect(isBareSpecifier("../bar")).toBe(false);
|
||||
expect(isBareSpecifier("/abs/path")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty specifiers", ({ expect }) => {
|
||||
expect(isBareSpecifier("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject Node.js built-ins (with and without prefix)", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||
expect(isBareSpecifier("fs")).toBe(false);
|
||||
expect(isBareSpecifier("node:child_process")).toBe(false);
|
||||
expect(isBareSpecifier("child_process")).toBe(false);
|
||||
expect(isBareSpecifier("assert")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject Cloudflare/workerd built-ins", ({ expect }) => {
|
||||
expect(isBareSpecifier("cloudflare:workers")).toBe(false);
|
||||
expect(isBareSpecifier("workerd:unsafe")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject bundler virtual modules", ({ expect }) => {
|
||||
expect(isBareSpecifier("virtual:react-router")).toBe(false);
|
||||
expect(isBareSpecifier("wrangler:modules-watch")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject ALL_CAPS virtual modules", ({ expect }) => {
|
||||
expect(isBareSpecifier("__VITEST_POOL_WORKERS_DEFINES")).toBe(false);
|
||||
expect(isBareSpecifier("__BUILD_CONFIG")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject specifiers that are not package-name shaped", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(isBareSpecifier(",")).toBe(false);
|
||||
expect(isBareSpecifier("some random text")).toBe(false);
|
||||
expect(isBareSpecifier("ASSERT.IN.MIXED-CASE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractBareImports()", () => {
|
||||
it("should extract named imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`import { x } from "lodash";`);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should extract default imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`import x from "lodash";`);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should extract namespace imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`import * as x from "lodash";`);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should extract side-effect imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`import "lodash";`);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should extract re-exports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`export { x } from "lodash";`);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should extract require() calls", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`const x = require("lodash"); require("foo");`
|
||||
);
|
||||
expect([...imports].sort()).toEqual(["foo", "lodash"]);
|
||||
});
|
||||
|
||||
it("should extract dynamic import() calls with string literals", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const imports = await extractBareImports(
|
||||
`const x = await import("lodash");`
|
||||
);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should strip subpath imports down to package name", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const imports = await extractBareImports(
|
||||
`import x from "semver/functions/satisfies.js";`
|
||||
);
|
||||
expect([...imports]).toEqual(["semver"]);
|
||||
});
|
||||
|
||||
it("should skip relative imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`import x from "./foo"; import y from "../bar";`
|
||||
);
|
||||
expect([...imports]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip Node built-in imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`import fs from "node:fs"; const path = require("node:path");`
|
||||
);
|
||||
expect([...imports]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip Cloudflare/workerd built-in imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`import { env } from "cloudflare:workers"; import x from "workerd:unsafe";`
|
||||
);
|
||||
expect([...imports]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip imports inside line comments", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`// import x from "lodash";\nimport y from "react";`
|
||||
);
|
||||
expect([...imports]).toEqual(["react"]);
|
||||
});
|
||||
|
||||
it("should skip imports inside block comments", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`/* import x from "lodash"; */\nimport y from "react";`
|
||||
);
|
||||
expect([...imports]).toEqual(["react"]);
|
||||
});
|
||||
|
||||
it("should skip imports inside template literals (regression: createViteConfig)", async ({
|
||||
expect,
|
||||
}) => {
|
||||
// Regression test: wrangler's createViteConfig bundles the literal text
|
||||
// of a vite.config.ts into a template literal. The regex-based scanner
|
||||
// used to match `import { defineConfig } from "vite"` as a real import.
|
||||
const imports = await extractBareImports(
|
||||
[
|
||||
"function createViteConfig() {",
|
||||
' const content = `import { cloudflare } from "@cloudflare/vite-plugin";',
|
||||
'import { defineConfig } from "vite";',
|
||||
"",
|
||||
"export default defineConfig({",
|
||||
"\tplugins: [cloudflare()],",
|
||||
"});",
|
||||
"`;",
|
||||
" return content;",
|
||||
"}",
|
||||
'import real from "react";',
|
||||
].join("\n")
|
||||
);
|
||||
expect([...imports]).toEqual(["react"]);
|
||||
});
|
||||
|
||||
it("should skip imports inside single- and double-quoted strings", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const imports = await extractBareImports(
|
||||
[
|
||||
'const a = "import { x } from \\"lodash\\";";',
|
||||
"const b = 'import { y } from \"react\";';",
|
||||
'import real from "commander";',
|
||||
].join("\n")
|
||||
);
|
||||
expect([...imports]).toEqual(["commander"]);
|
||||
});
|
||||
|
||||
it("should not match `from` keywords that aren't part of import/export", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const imports = await extractBareImports(
|
||||
`function foo() { return "hello"; } const z = "from";`
|
||||
);
|
||||
expect([...imports]).toEqual([]);
|
||||
});
|
||||
|
||||
it("should deduplicate imports", async ({ expect }) => {
|
||||
const imports = await extractBareImports(
|
||||
`import { x } from "lodash";\nimport { y } from "lodash";`
|
||||
);
|
||||
expect([...imports]).toEqual(["lodash"]);
|
||||
});
|
||||
|
||||
it("should handle multiple imports in one file", async ({ expect }) => {
|
||||
const imports = await extractBareImports(`
|
||||
import { x } from "lodash";
|
||||
import y from "react";
|
||||
import "polyfill";
|
||||
const z = require("commander");
|
||||
`);
|
||||
expect([...imports].sort()).toEqual([
|
||||
"commander",
|
||||
"lodash",
|
||||
"polyfill",
|
||||
"react",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDistImports()", () => {
|
||||
it("should pass when all imports are declared dependencies", ({ expect }) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
dependencies: { lodash: "^4.0.0", react: "^18.0.0" },
|
||||
},
|
||||
new Set(["lodash", "react"])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should pass when imports are peerDependencies", ({ expect }) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
peerDependencies: { vitest: "^4.0.0" },
|
||||
},
|
||||
new Set(["vitest"])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should allow self-imports without error", ({ expect }) => {
|
||||
const errors = validateDistImports(
|
||||
"my-package",
|
||||
{ name: "my-package" },
|
||||
new Set(["my-package"])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should flag devDependency-only imports with a tailored message", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
devDependencies: { undici: "^7.0.0" },
|
||||
},
|
||||
new Set(["undici"])
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"undici"');
|
||||
expect(errors[0]).toContain("only a devDependency");
|
||||
expect(errors[0]).toContain("IGNORED_DIST_IMPORTS");
|
||||
});
|
||||
|
||||
it("should flag undeclared imports", ({ expect }) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{ name: "test-package" },
|
||||
new Set(["mystery-package"])
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"mystery-package"');
|
||||
expect(errors[0]).toContain(
|
||||
"not declared in dependencies or peerDependencies"
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip imports in the ignored list", ({ expect }) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
devDependencies: { "@netlify/build-info": "^1.0.0" },
|
||||
},
|
||||
new Set(["react-router", "@angular/ssr"]),
|
||||
["react-router", "@angular/ssr"]
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still flag non-ignored imports when ignored list is non-empty", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validateDistImports(
|
||||
"test-package",
|
||||
{
|
||||
name: "test-package",
|
||||
devDependencies: { undici: "^7.0.0" },
|
||||
},
|
||||
new Set(["undici", "react-router"]),
|
||||
["react-router"]
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"undici"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntryPointPaths()", () => {
|
||||
it("should collect main field", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
main: "dist/index.js",
|
||||
});
|
||||
expect(paths).toEqual(["dist/index.js"]);
|
||||
});
|
||||
|
||||
it("should collect module field", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
module: "dist/index.mjs",
|
||||
});
|
||||
expect(paths).toEqual(["dist/index.mjs"]);
|
||||
});
|
||||
|
||||
it("should walk nested exports object", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
exports: {
|
||||
".": {
|
||||
import: "./dist/index.mjs",
|
||||
require: "./dist/index.cjs",
|
||||
types: "./dist/index.d.ts",
|
||||
},
|
||||
"./test-helpers": {
|
||||
import: "./dist/test-helpers/index.mjs",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(paths.sort()).toEqual([
|
||||
"./dist/index.cjs",
|
||||
"./dist/index.d.ts",
|
||||
"./dist/index.mjs",
|
||||
"./dist/test-helpers/index.mjs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should collect string bin entries", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
bin: "./bin/cli.js",
|
||||
});
|
||||
expect(paths).toEqual(["./bin/cli.js"]);
|
||||
});
|
||||
|
||||
it("should collect object bin entries", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
bin: { foo: "./bin/foo.js", bar: "./bin/bar.js" },
|
||||
});
|
||||
expect(paths.sort()).toEqual(["./bin/bar.js", "./bin/foo.js"]);
|
||||
});
|
||||
|
||||
it("should deduplicate paths across fields", ({ expect }) => {
|
||||
const paths = getEntryPointPaths({
|
||||
name: "p",
|
||||
main: "./dist/index.js",
|
||||
module: "./dist/index.js",
|
||||
exports: { ".": "./dist/index.js" },
|
||||
});
|
||||
expect(paths).toEqual(["./dist/index.js"]);
|
||||
});
|
||||
|
||||
it("should return empty array when no entry points are declared", ({
|
||||
expect,
|
||||
}) => {
|
||||
const paths = getEntryPointPaths({ name: "p" });
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicPackages()", () => {
|
||||
it("should return only non-private packages", async ({ expect }) => {
|
||||
const packages = await getPublicPackages();
|
||||
|
||||
// All returned packages should be non-private
|
||||
for (const pkg of packages) {
|
||||
expect(pkg.packageJson.private).not.toBe(true);
|
||||
}
|
||||
|
||||
// Should include known public packages
|
||||
const packageNames = packages.map((p) => p.packageJson.name);
|
||||
expect(packageNames).toContain("wrangler");
|
||||
expect(packageNames).toContain("miniflare");
|
||||
expect(packageNames).toContain("create-cloudflare");
|
||||
expect(packageNames).toContain("@cloudflare/autoconfig");
|
||||
});
|
||||
|
||||
it("should not include private packages", async ({ expect }) => {
|
||||
const packages = await getPublicPackages();
|
||||
const packageNames = packages.map((p) => p.packageJson.name);
|
||||
|
||||
// These are known private packages
|
||||
expect(packageNames).not.toContain("@cloudflare/workers-shared");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { parseCatalog } from "../validate-catalog-usage";
|
||||
import {
|
||||
isPinnedVersion,
|
||||
validateCatalogPins,
|
||||
validatePackagePins,
|
||||
} from "../validate-pinned-dependencies";
|
||||
|
||||
describe("isPinnedVersion()", () => {
|
||||
it("should accept exact versions", ({ expect }) => {
|
||||
expect(isPinnedVersion("1.2.3")).toBe(true);
|
||||
expect(isPinnedVersion("0.0.14")).toBe(true);
|
||||
expect(isPinnedVersion("1.20260529.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept exact prerelease and build versions", ({ expect }) => {
|
||||
expect(isPinnedVersion("4.1.0-beta.10")).toBe(true);
|
||||
expect(isPinnedVersion("2.0.0-rc.24")).toBe(true);
|
||||
expect(isPinnedVersion("1.2.3+build.5")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject caret and tilde ranges", ({ expect }) => {
|
||||
expect(isPinnedVersion("^1.2.3")).toBe(false);
|
||||
expect(isPinnedVersion("~5.8.3")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject comparator ranges", ({ expect }) => {
|
||||
expect(isPinnedVersion(">1.20260305.0 <2.0.0-0")).toBe(false);
|
||||
expect(isPinnedVersion(">=1.0.0")).toBe(false);
|
||||
expect(isPinnedVersion("^6.1.0 || ^7.0.0 || ^8.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject wildcards and partial versions", ({ expect }) => {
|
||||
expect(isPinnedVersion("*")).toBe(false);
|
||||
expect(isPinnedVersion("1.x")).toBe(false);
|
||||
expect(isPinnedVersion("1.2")).toBe(false);
|
||||
expect(isPinnedVersion("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject non-version specifiers", ({ expect }) => {
|
||||
expect(isPinnedVersion("")).toBe(false);
|
||||
expect(isPinnedVersion("latest")).toBe(false);
|
||||
expect(isPinnedVersion("workspace:*")).toBe(false);
|
||||
expect(isPinnedVersion("catalog:default")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCatalogPins()", () => {
|
||||
it("should pass when all entries are pinned", ({ expect }) => {
|
||||
const errors = validateCatalogPins(
|
||||
new Map([
|
||||
["undici", "7.24.8"],
|
||||
["esbuild", "0.28.1"],
|
||||
["youch", "4.1.0-beta.10"],
|
||||
])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should flag ranged entries", ({ expect }) => {
|
||||
const errors = validateCatalogPins(
|
||||
new Map([
|
||||
["undici", "7.24.8"],
|
||||
["ci-info", "^4.4.0"],
|
||||
["typescript", "~5.8.3"],
|
||||
])
|
||||
);
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0]).toContain('"ci-info"');
|
||||
expect(errors[0]).toContain('"^4.4.0"');
|
||||
expect(errors[1]).toContain('"typescript"');
|
||||
});
|
||||
|
||||
it("should skip entries in the exceptions allowlist", ({ expect }) => {
|
||||
const errors = validateCatalogPins(
|
||||
new Map([["@cloudflare/workers-types", "^4.20260529.1"]]),
|
||||
new Set(["@cloudflare/workers-types"])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still flag non-excepted ranged entries", ({ expect }) => {
|
||||
const errors = validateCatalogPins(
|
||||
new Map([
|
||||
["@cloudflare/workers-types", "^4.20260529.1"],
|
||||
["vite", "^8.0.12"],
|
||||
]),
|
||||
new Set(["@cloudflare/workers-types"])
|
||||
);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"vite"');
|
||||
});
|
||||
|
||||
it("should use the default exceptions allowlist", ({ expect }) => {
|
||||
const errors = validateCatalogPins(
|
||||
new Map([["@cloudflare/workers-types", "^4.20260529.1"]])
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePackagePins()", () => {
|
||||
it("should pass when all dependencies are pinned", ({ expect }) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
dependencies: { "blake3-wasm": "2.1.5", "path-to-regexp": "6.3.0" },
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should flag ranged dependencies", ({ expect }) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
dependencies: { sharp: "^0.34.5" },
|
||||
});
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"sharp"');
|
||||
expect(errors[0]).toContain('"^0.34.5"');
|
||||
expect(errors[0]).toContain("dependencies");
|
||||
});
|
||||
|
||||
it("should flag ranged optionalDependencies", ({ expect }) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
optionalDependencies: { fsevents: "~2.3.2" },
|
||||
});
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('"fsevents"');
|
||||
expect(errors[0]).toContain("optionalDependencies");
|
||||
});
|
||||
|
||||
it("should skip workspace, catalog, npm, link and file specifiers", ({
|
||||
expect,
|
||||
}) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
dependencies: {
|
||||
miniflare: "workspace:*",
|
||||
"@cloudflare/kv-asset-handler": "workspace:^",
|
||||
esbuild: "catalog:default",
|
||||
aliased: "npm:other@1.2.3",
|
||||
linked: "link:../other",
|
||||
filed: "file:../other",
|
||||
},
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should ignore peerDependencies and devDependencies", ({ expect }) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
peerDependencies: { vitest: "^4.1.0", react: "^17.0.2 || ^18.2.21" },
|
||||
devDependencies: { typescript: "^5.0.0", esbuild: "^0.20.0" },
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("should report multiple violations across sections", ({ expect }) => {
|
||||
const errors = validatePackagePins("test-package", "test-package", {
|
||||
name: "test-package",
|
||||
dependencies: { zod: "^3.25.76", "blake3-wasm": "2.1.5" },
|
||||
optionalDependencies: { fsevents: "~2.3.2" },
|
||||
});
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0]).toContain('"zod"');
|
||||
expect(errors[1]).toContain('"fsevents"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCatalog()", () => {
|
||||
it("should parse quoted and unquoted entries", ({ expect }) => {
|
||||
const catalog = parseCatalog(
|
||||
[
|
||||
"packages:",
|
||||
" - packages/*",
|
||||
"",
|
||||
"minimumReleaseAge: 1440",
|
||||
"",
|
||||
"catalog:",
|
||||
" # a comment",
|
||||
' "@cloudflare/workers-types": "^4.20260529.1"',
|
||||
' undici: "7.24.8"',
|
||||
" '@vitest/runner': 4.1.0",
|
||||
' typescript: "~5.8.3"',
|
||||
"",
|
||||
"overrides:",
|
||||
' "@types/node": "$@types/node"',
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
expect(catalog.get("@cloudflare/workers-types")).toBe("^4.20260529.1");
|
||||
expect(catalog.get("undici")).toBe("7.24.8");
|
||||
expect(catalog.get("@vitest/runner")).toBe("4.1.0");
|
||||
expect(catalog.get("typescript")).toBe("~5.8.3");
|
||||
// Should stop at the next top-level key.
|
||||
expect(catalog.has("@types/node")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { validateDescription } from "../validate-pr-description";
|
||||
|
||||
describe("validateDescription()", () => {
|
||||
it("should skip validation with the `ci:skip-pr-description-validation` label", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(
|
||||
validateDescription("", "", '["ci:skip-pr-description-validation"]', "[]")
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should show errors with default template + TODOs checked", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(
|
||||
validateDescription(
|
||||
"",
|
||||
`Fixes #[insert GH or internal issue link(s)].
|
||||
|
||||
_Describe your change..._
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Please don't delete the checkboxes <3
|
||||
The following selections do not need to be completed if this PR only contains changes to .md files
|
||||
-->
|
||||
|
||||
- Tests
|
||||
- [ ] Tests included/updated
|
||||
- [ ] Automated tests not possible - manual testing has been completed as follows:
|
||||
- [ ] Additional testing not necessary because:
|
||||
- Public documentation
|
||||
- [ ] Cloudflare docs PR(s): <!--e.g. <https://github.com/cloudflare/cloudflare-docs/pull/>...-->
|
||||
- [ ] Documentation not necessary because:
|
||||
|
||||
<!--
|
||||
Have you read our [Contributing guide](https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md)?
|
||||
In particular, for non-trivial changes, please always engage on the issue or create a discussion or feature request issue first before writing your code.
|
||||
-->
|
||||
`,
|
||||
"[]",
|
||||
"[]"
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"Your PR must include tests, or provide justification for why no tests are required in the PR description and apply the \`ci:no-tests\` label",
|
||||
"Your PR doesn't include a changeset. Either include one (following the instructions in CONTRIBUTING.md) or add the 'ci:no-changeset-required' label to bypass this check. Most PRs should have a changeset, so only bypass this check if you're sure that your change doesn't need one: see https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md#changesets for more details.",
|
||||
"Your PR must include documentation (in the form of a link to a Cloudflare Docs issue or PR), or provide justification for why no documentation is required",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should bypass changesets check with label", ({ expect }) => {
|
||||
expect(
|
||||
validateDescription(
|
||||
"",
|
||||
`Fixes #[insert GH or internal issue link(s)].
|
||||
|
||||
_Describe your change..._
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Please don't delete the checkboxes <3
|
||||
The following selections do not need to be completed if this PR only contains changes to .md files
|
||||
-->
|
||||
|
||||
- Tests
|
||||
- [ ] Tests included/updated
|
||||
- [ ] Automated tests not possible - manual testing has been completed as follows:
|
||||
- [x] Additional testing not necessary because: test
|
||||
- Public documentation
|
||||
- [ ] Cloudflare docs PR(s): <!--e.g. <https://github.com/cloudflare/cloudflare-docs/pull/>...-->
|
||||
- [x] Documentation not necessary because: test
|
||||
|
||||
<!--
|
||||
Have you read our [Contributing guide](https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md)?
|
||||
In particular, for non-trivial changes, please always engage on the issue or create a discussion or feature request issue first before writing your code.
|
||||
-->`,
|
||||
'["ci:no-changeset-required"]',
|
||||
"[]"
|
||||
)
|
||||
).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
it("should accept everything included", ({ expect }) => {
|
||||
expect(
|
||||
validateDescription(
|
||||
"",
|
||||
`Fixes #[insert GH or internal issue link(s)].
|
||||
|
||||
_Describe your change..._
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Please don't delete the checkboxes <3
|
||||
The following selections do not need to be completed if this PR only contains changes to .md files
|
||||
-->
|
||||
|
||||
- Tests
|
||||
- [x] Tests included/updated
|
||||
- [ ] Automated tests not possible - manual testing has been completed as follows:
|
||||
- [ ] Additional testing not necessary because:
|
||||
- Public documentation
|
||||
- [x] Cloudflare docs PR(s): https://github.com/cloudflare/cloudflare-docs/pull/100
|
||||
- [ ] Documentation not necessary because:
|
||||
|
||||
<!--
|
||||
Have you read our [Contributing guide](https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md)?
|
||||
In particular, for non-trivial changes, please always engage on the issue or create a discussion or feature request issue first before writing your code.
|
||||
-->
|
||||
`,
|
||||
"[]",
|
||||
'[".changeset/hello-world.md"]'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should accept a docs link", ({ expect }) => {
|
||||
expect(
|
||||
validateDescription(
|
||||
"",
|
||||
`## What this PR solves / how to test
|
||||
|
||||
Fixes [AA-000](https://jira.cfdata.org/browse/AA-000).
|
||||
|
||||
## Author has addressed the following
|
||||
|
||||
- Tests
|
||||
- [ ] TODO (before merge)
|
||||
- [x] Tests included/updated
|
||||
- [ ] Automated tests not possible - manual testing has been completed as follows:
|
||||
- [ ] Additional testing not necessary because:
|
||||
- E2E Tests CI Job required? (Use "e2e" label or ask maintainer to run separately)
|
||||
- [ ] I don't know
|
||||
- [ ] Required
|
||||
- [x] Not required because: test
|
||||
- Public documentation
|
||||
- [ ] TODO (before merge)
|
||||
- [x] Cloudflare docs PR(s): https://developers.cloudflare.com/workers/something-here/
|
||||
- [ ] Documentation not necessary because:
|
||||
`,
|
||||
"[]",
|
||||
'[".changeset/hello-world.md"]'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars -- this script reads CI environment variables */
|
||||
if (require.main === module) {
|
||||
const status = [];
|
||||
|
||||
if (process.env.PUBLISH_STATUS === "failure") {
|
||||
if (process.env.HAS_CHANGESETS === "true") {
|
||||
console.log(
|
||||
"Version PR creation failed. Please retry the job if needed."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
status.push({
|
||||
label: "NPM publish",
|
||||
details: "Packages failed to publish",
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.DEPLOYMENT_STATUS) {
|
||||
try {
|
||||
const deploymentStatus = JSON.parse(
|
||||
process.env.DEPLOYMENT_STATUS as string
|
||||
);
|
||||
|
||||
for (const [pkg, err] of Object.entries(deploymentStatus)) {
|
||||
status.push({
|
||||
label: pkg,
|
||||
details: err,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
status.push({
|
||||
label: "Deployment status",
|
||||
details: `Failed to parse deployment status: ${e}. Received: "${process.env.DEPLOYMENT_STATUS}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (status.length > 0) {
|
||||
void fetch(
|
||||
"https://devprod-status-bot.devprod.workers.dev/release-failure",
|
||||
{
|
||||
body: JSON.stringify({ status, url: process.env.RUN_URL }),
|
||||
headers: {
|
||||
"X-Auth-Header": process.env.TOKEN as string,
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { compare } from "semver";
|
||||
|
||||
/**
|
||||
* For Trusted Publishing to work, we need npm version 11.5.1 or higher.
|
||||
*/
|
||||
function checkNpmVersion() {
|
||||
const npmVersionBuffer = execSync("npm --version");
|
||||
const npmVersion = npmVersionBuffer.toString().trim();
|
||||
if (compare(npmVersion, "11.5.1") === -1) {
|
||||
console.error(
|
||||
`Error: npm version 11.5.1 or higher is required for Trusted Publishing to work, found version ${npmVersion}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
checkNpmVersion();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[2] === "check") {
|
||||
findDeployablePackageNames();
|
||||
} else {
|
||||
deployNonNpmPackages(getUpdatedPackages(), findDeployablePackageNames());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy all packages that had updates but are not deployed to npm automatically by the changesets tooling.
|
||||
*
|
||||
* @param updatedPackages An array of all the packages that were updated by changesets.
|
||||
* @param deployablePackageNames A set of the names of packages that can be deployed by this script.
|
||||
*/
|
||||
export function deployNonNpmPackages(
|
||||
updatedPackages: UpdatedPackage[],
|
||||
deployablePackageNames: Set<string>
|
||||
) {
|
||||
let deployedPackageCount = 0;
|
||||
console.log("Checking for non-npm packages to deploy...");
|
||||
const deploymentErrors = new Map<string, string>();
|
||||
for (const pkg of updatedPackages) {
|
||||
if (deployablePackageNames.has(pkg.name)) {
|
||||
console.log(`Package "${pkg.name}@${pkg.version}": deploying...`);
|
||||
deployPackage(pkg.name, deploymentErrors);
|
||||
deployedPackageCount++;
|
||||
} else {
|
||||
console.log(
|
||||
`Package "${pkg.name}@${pkg.version}": already deployed via npm.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (deployedPackageCount === 0) {
|
||||
console.log("No non-npm packages to deploy.");
|
||||
} else {
|
||||
console.log(`Deployed ${deployedPackageCount} non-npm packages.`);
|
||||
}
|
||||
writeFileSync(
|
||||
"deployment-status.json",
|
||||
JSON.stringify(Object.fromEntries(deploymentErrors.entries()))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all the packages that got bumped by changesets from the PUBLISHED_PACKAGES environment variable.
|
||||
*/
|
||||
export function getUpdatedPackages(): UpdatedPackage[] {
|
||||
const packages = JSON.parse(process.env.PUBLISHED_PACKAGES ?? "[]");
|
||||
|
||||
assert(
|
||||
Array.isArray(packages),
|
||||
`Expected PUBLISHED_PACKAGES to be an array but got ${typeof packages}.`
|
||||
);
|
||||
packages.forEach((p, i) => {
|
||||
assert(
|
||||
typeof p === "object" && p !== null,
|
||||
`Expected item ${i} in array to be an array but got ${typeof p}.`
|
||||
);
|
||||
assert(
|
||||
typeof p.name === "string",
|
||||
`Expected item ${i} to have a "name" property of type string but got ${p.name}.`
|
||||
);
|
||||
assert(
|
||||
typeof p.version === "string",
|
||||
`Expected item ${i} to have a "version" property of type string but got ${p.version}.`
|
||||
);
|
||||
});
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for all the packages (under the top-level "packages" directory of the monorepo)
|
||||
* that can be deployed using this script.
|
||||
*
|
||||
* This is determined by the package containing a package.json "workers-sdk": { "deploy": true }`
|
||||
*/
|
||||
export function findDeployablePackageNames(): Set<string> {
|
||||
const packagesDirectory = resolve(__dirname, "../../packages");
|
||||
const allPackageDirectories = readdirSync(packagesDirectory).map((p) =>
|
||||
resolve(packagesDirectory, p)
|
||||
);
|
||||
const allPackages: PackageJSON[] = [];
|
||||
for (const dir of allPackageDirectories) {
|
||||
try {
|
||||
allPackages.push(
|
||||
JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"))
|
||||
);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
const deployablePackages = new Set<string>();
|
||||
for (const pkg of allPackages) {
|
||||
if (pkg["workers-sdk"]?.deploy) {
|
||||
assert(
|
||||
pkg.scripts?.deploy !== undefined,
|
||||
`Expected package "${pkg.name}" to have a deploy script`
|
||||
);
|
||||
deployablePackages.add(pkg.name);
|
||||
}
|
||||
}
|
||||
return deployablePackages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to run `pnpm deploy` on the given package in the monorepo.
|
||||
*
|
||||
* If this deployment fails, log an error and continue.
|
||||
*
|
||||
* @param pkgName the package to deploy
|
||||
*/
|
||||
export function deployPackage(
|
||||
pkgName: string,
|
||||
deploymentErrors: Map<string, string>
|
||||
) {
|
||||
try {
|
||||
spawnSync(`pnpm`, [`-F`, pkgName, `run`, `deploy`], {
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`::error::Failed to deploy "${pkgName}".`);
|
||||
console.error(
|
||||
"Work out why this happened and then potentially run a manual deployment."
|
||||
);
|
||||
console.error(e);
|
||||
deploymentErrors.set(pkgName, String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdatedPackage = { name: string; version: string };
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
scripts?: Record<string, unknown>;
|
||||
"workers-sdk"?: {
|
||||
deploy?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Validates that workspace packages use `catalog:` references for any dependency
|
||||
* that exists in the pnpm catalog. Exceptions: `workspace:` refs, `npm:` aliases,
|
||||
* and packages under `templates/` (scaffolded for end users).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
|
||||
// Deps that are deliberately pinned outside the catalog (e.g. workerd is
|
||||
// bumped in coordinated PRs with its own automation).
|
||||
const IGNORED_DEPS = new Set(["workerd"]);
|
||||
|
||||
/**
|
||||
* Parses the `catalog:` block of a pnpm-workspace.yaml into a map of
|
||||
* dependency name -> version specifier.
|
||||
*/
|
||||
export function parseCatalog(workspaceYaml: string): Map<string, string> {
|
||||
const catalog = new Map<string, string>();
|
||||
let inCatalog = false;
|
||||
|
||||
for (const line of workspaceYaml.split("\n")) {
|
||||
if (line.startsWith("catalog:")) {
|
||||
inCatalog = true;
|
||||
continue;
|
||||
}
|
||||
if (inCatalog && /^\S/.test(line)) {
|
||||
break;
|
||||
}
|
||||
if (!inCatalog) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(
|
||||
/^["']?(@?[^"':]+)["']?\s*:\s*["']?([^"'#\s]+)["']?/
|
||||
);
|
||||
if (match) {
|
||||
catalog.set(match[1].trim(), match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the pnpm catalog as a map of dependency name -> version specifier.
|
||||
*/
|
||||
export function loadCatalog(): Map<string, string> {
|
||||
const content = readFileSync(resolve(ROOT, "pnpm-workspace.yaml"), "utf-8");
|
||||
return parseCatalog(content);
|
||||
}
|
||||
|
||||
function loadCatalogDeps(): Set<string> {
|
||||
return new Set(loadCatalog().keys());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("::group::Checking catalog usage");
|
||||
|
||||
const catalogDeps = loadCatalogDeps();
|
||||
const errors: string[] = [];
|
||||
|
||||
const packageJsonPaths = await glob(
|
||||
[
|
||||
"package.json",
|
||||
"packages/*/package.json",
|
||||
"packages/vite-plugin-cloudflare/playground/*/package.json",
|
||||
"packages/vite-plugin-cloudflare/playground/package.json",
|
||||
"fixtures/*/package.json",
|
||||
"tools/package.json",
|
||||
],
|
||||
{ cwd: ROOT, absolute: true }
|
||||
);
|
||||
|
||||
for (const pkgPath of packageJsonPaths) {
|
||||
if (pkgPath.includes("/templates/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const rel = pkgPath.replace(ROOT + "/", "");
|
||||
|
||||
for (const section of ["dependencies", "devDependencies"] as const) {
|
||||
for (const [name, version] of Object.entries(
|
||||
(pkg[section] ?? {}) as Record<string, string>
|
||||
)) {
|
||||
if (
|
||||
!catalogDeps.has(name) ||
|
||||
IGNORED_DEPS.has(name) ||
|
||||
version.startsWith("catalog:") ||
|
||||
version.startsWith("workspace:") ||
|
||||
version.startsWith("npm:")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(
|
||||
`${rel}: "${name}" uses "${version}" in ${section} but should use "catalog:default"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Catalog usage violations:" +
|
||||
errors.map((e) => `\n- ${e}`).join("")
|
||||
);
|
||||
} else {
|
||||
console.log("All catalog dependencies are correctly referenced.");
|
||||
}
|
||||
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import parseChangeset from "@changesets/parse";
|
||||
|
||||
if (require.main === module) {
|
||||
const packages = findPackages();
|
||||
const changesets = readChangesets(resolve(__dirname, "../../.changeset"));
|
||||
const errors = validateChangesets(packages, changesets);
|
||||
if (errors.length > 0) {
|
||||
console.error("Validation errors in changesets:");
|
||||
for (const error of errors) {
|
||||
console.error("- ", error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChangesets(
|
||||
packages: Map<string, PackageJSON>,
|
||||
changesets: ChangesetFile[]
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
changesets.map(({ file, contents }) => {
|
||||
try {
|
||||
const changeset = parseChangeset(contents);
|
||||
for (const release of changeset.releases) {
|
||||
const targetPackage = packages.get(release.name);
|
||||
if (!targetPackage) {
|
||||
errors.push(
|
||||
`Unknown package name "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (release.type === "major" && targetPackage?.private !== true) {
|
||||
errors.push(
|
||||
`Major version bumps are not allowed for package "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
if (!["major", "minor", "patch", "none"].includes(release.type)) {
|
||||
errors.push(
|
||||
`Invalid type "${release.type}" for package "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
errors.push(e.toString() + `at file "${file}"`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function readChangesets(changesetDir: string): ChangesetFile[] {
|
||||
return readdirSync(changesetDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.startsWith(".") && file.endsWith(".md") && file !== "README.md"
|
||||
)
|
||||
.map((file) => ({
|
||||
file,
|
||||
contents: readFileSync(resolve(changesetDir, file), "utf-8"),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for all the packages (under the top-level "packages" directory of the monorepo) that can have changesets.
|
||||
*
|
||||
* This is determined by the package containing a package.json that is public or
|
||||
* is marked as `"private": true` and has a `deploy` script.
|
||||
*/
|
||||
export function findPackages(): Map<string, PackageJSON> {
|
||||
const packagesDirectory = resolve(__dirname, "../../packages");
|
||||
const allPackageDirectories = readdirSync(packagesDirectory).map((p) =>
|
||||
resolve(packagesDirectory, p)
|
||||
);
|
||||
const allPackages: PackageJSON[] = [];
|
||||
for (const dir of allPackageDirectories) {
|
||||
try {
|
||||
allPackages.push(
|
||||
JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"))
|
||||
);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
const deployablePackages = new Map<string, PackageJSON>();
|
||||
for (const pkg of allPackages) {
|
||||
if (!pkg.private || pkg.scripts?.deploy !== undefined) {
|
||||
deployablePackages.set(pkg.name, pkg);
|
||||
}
|
||||
}
|
||||
return deployablePackages;
|
||||
}
|
||||
|
||||
export type ChangesetFile = {
|
||||
file: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
scripts?: Record<string, unknown>;
|
||||
"workers-sdk"?: {
|
||||
deploy?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking fixtures");
|
||||
const errors = checkFixtures();
|
||||
if (errors.length > 0) {
|
||||
console.error("::error::Fixture checks:" + errors.map((e) => `\n- ${e}`));
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
function getFixtures() {
|
||||
const fixturesDirectory = resolve(__dirname, "../../fixtures");
|
||||
const allFixtureDirectories = readdirSync(fixturesDirectory);
|
||||
return allFixtureDirectories
|
||||
.map((dir) => {
|
||||
return {
|
||||
dir,
|
||||
packageJson: resolve(fixturesDirectory, dir, "package.json"),
|
||||
};
|
||||
})
|
||||
.filter(({ packageJson }) => {
|
||||
return existsSync(packageJson);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that we don't accidentally create git tags and releases for fixtures
|
||||
* by someone inadvertently adding a version to their package.json or making them non-private.
|
||||
*
|
||||
* Check the package.json for each fixture and ensure that they are all private and have no `version` property.
|
||||
*/
|
||||
export function checkFixtures(): string[] {
|
||||
const allFixtures = getFixtures();
|
||||
const errors: string[] = [];
|
||||
for (const { dir, packageJson } of allFixtures) {
|
||||
try {
|
||||
console.log(`- ${dir}`);
|
||||
const fixturePackage = JSON.parse(
|
||||
readFileSync(packageJson, "utf-8")
|
||||
) as PackageJSON;
|
||||
|
||||
// Check the package is not deployable.
|
||||
if (fixturePackage.private !== true) {
|
||||
errors.push(
|
||||
`Fixture "${dir}" is not private. Add \`"private": true\` to its package.json`
|
||||
);
|
||||
}
|
||||
|
||||
// Check the package does not have a version.
|
||||
if (fixturePackage.version) {
|
||||
errors.push(
|
||||
`Fixture "${dir}" has the disallowed "version" property. Please remove this.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check the package has a name starting with "@fixture/".
|
||||
if (!fixturePackage.name.startsWith("@fixture/")) {
|
||||
errors.push(
|
||||
`Fixture in directory "fixtures/${dir}" has a name that does not start with "@fixture/". Please rename it to start with "@fixture/".`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push(
|
||||
`Unable to load or parse fixture "${dir}" package. Please check it has a valid package.json.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
version?: string;
|
||||
};
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Validates that packages explicitly declare their external (non-bundled) dependencies.
|
||||
*
|
||||
* This prevents accidental dependency chain poisoning where a dependency of ours
|
||||
* has not pinned its versions and allows users to install unexpected upstream
|
||||
* transitive dependencies.
|
||||
*
|
||||
* Packages should bundle their dependencies into the distributable code via
|
||||
* devDependencies. Any dependency that MUST remain external (native binaries,
|
||||
* WASM, runtime-resolved code) should be:
|
||||
* 1. Listed in `dependencies` (or `peerDependencies`) in package.json
|
||||
* 2. Listed in `scripts/deps.ts` with EXTERNAL_DEPENDENCIES export
|
||||
* 3. Documented with a comment explaining WHY it can't be bundled
|
||||
*
|
||||
* In addition to validating the manifest, this script also scans the actual
|
||||
* built/published files (from the `files` field in package.json) for bare
|
||||
* import specifiers and checks they all resolve to a declared runtime
|
||||
* dependency or peer dependency. This catches cases where a bundler config's
|
||||
* `external` list drifts from `package.json` — for example, a devDependency
|
||||
* being incorrectly externalized would leave the published bundle with an
|
||||
* unresolved import.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
export interface PackageJSON {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
main?: string;
|
||||
module?: string;
|
||||
exports?: unknown;
|
||||
bin?: string | Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
dir: string;
|
||||
packageJson: PackageJSON;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking package dependencies");
|
||||
checkPackageDependencies()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Package dependency checks:" + errors.map((e) => `\n- ${e}`)
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all non-private package.json files under packages/
|
||||
*/
|
||||
export async function getPublicPackages(): Promise<PackageInfo[]> {
|
||||
const packagesDir = resolve(__dirname, "../../packages");
|
||||
const packageJsonPaths = await glob("*/package.json", {
|
||||
cwd: packagesDir,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
return packageJsonPaths
|
||||
.map((packageJsonPath) => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8")
|
||||
) as PackageJSON;
|
||||
return {
|
||||
dir: dirname(packageJsonPath),
|
||||
packageJson,
|
||||
};
|
||||
})
|
||||
.filter(({ packageJson }) => !packageJson.private);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all dependency names from a package.json dependencies object
|
||||
*/
|
||||
export function getAllDependencies(
|
||||
dependencies: Record<string, string> | undefined
|
||||
): string[] {
|
||||
if (!dependencies) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the non-workspace dependencies from a package.json
|
||||
*/
|
||||
export function getNonWorkspaceDependencies(
|
||||
dependencies: Record<string, string> | undefined
|
||||
): string[] {
|
||||
if (!dependencies) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(dependencies)
|
||||
.filter(([, version]) => !version.startsWith("workspace:"))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load EXTERNAL_DEPENDENCIES from a package's scripts/deps.ts
|
||||
*/
|
||||
export function loadExternalDependencies(packageDir: string): string[] | null {
|
||||
const depsModule = loadDepsModule(packageDir);
|
||||
if (!depsModule || !Array.isArray(depsModule.EXTERNAL_DEPENDENCIES)) {
|
||||
return null;
|
||||
}
|
||||
return depsModule.EXTERNAL_DEPENDENCIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load IGNORED_DIST_IMPORTS from a package's scripts/deps.ts.
|
||||
*
|
||||
* `IGNORED_DIST_IMPORTS` is an allowlist of package names that the dist-scan
|
||||
* validator should not flag as missing dependencies. Use this for legitimate
|
||||
* but unfixable patterns such as:
|
||||
* - Optional imports inside try/catch blocks in bundled libraries
|
||||
* (e.g. `@netlify/build-info` probing for installed frameworks)
|
||||
* - Optional native binaries (e.g. `@aws-sdk/signature-v4-crt`)
|
||||
* - Code paths that are only reachable when consumers also install the
|
||||
* listed package themselves
|
||||
*/
|
||||
export function loadIgnoredDistImports(packageDir: string): string[] {
|
||||
const depsModule = loadDepsModule(packageDir);
|
||||
if (!depsModule || !Array.isArray(depsModule.IGNORED_DIST_IMPORTS)) {
|
||||
return [];
|
||||
}
|
||||
return depsModule.IGNORED_DIST_IMPORTS;
|
||||
}
|
||||
|
||||
function loadDepsModule(packageDir: string): {
|
||||
EXTERNAL_DEPENDENCIES?: string[];
|
||||
IGNORED_DIST_IMPORTS?: string[];
|
||||
} | null {
|
||||
const depsFilePath = resolve(packageDir, "scripts/deps.ts");
|
||||
if (!existsSync(depsFilePath)) {
|
||||
return null;
|
||||
}
|
||||
// Use require with esbuild-register (which is already loaded)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require needed for esbuild-register compatibility
|
||||
return require(depsFilePath) as {
|
||||
EXTERNAL_DEPENDENCIES?: string[];
|
||||
IGNORED_DIST_IMPORTS?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single package's dependencies against its allowlist.
|
||||
* Returns an array of error messages (empty if valid).
|
||||
*/
|
||||
export function validatePackageDependencies(
|
||||
packageName: string,
|
||||
relativePath: string,
|
||||
packageJson: PackageJSON,
|
||||
externalDeps: string[] | null
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Get non-workspace dependencies
|
||||
const nonWorkspaceDeps = getNonWorkspaceDependencies(
|
||||
packageJson.dependencies
|
||||
);
|
||||
|
||||
// Skip packages with no non-workspace dependencies
|
||||
if (nonWorkspaceDeps.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check if allowlist exists
|
||||
if (externalDeps === null) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has ${nonWorkspaceDeps.length} non-workspace dependencies ` +
|
||||
`but no scripts/deps.ts file with EXTERNAL_DEPENDENCIES export.\n` +
|
||||
` Create packages/${relativePath}/scripts/deps.ts with an EXTERNAL_DEPENDENCIES export ` +
|
||||
`listing all dependencies that cannot be bundled, with comments explaining why.\n` +
|
||||
` Dependencies: ${nonWorkspaceDeps.join(", ")}`
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check for dependencies not in the allowlist
|
||||
const undeclaredDeps = nonWorkspaceDeps.filter(
|
||||
(dep) => !externalDeps.includes(dep)
|
||||
);
|
||||
|
||||
for (const dep of undeclaredDeps) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has dependency "${dep}" that is not listed in ` +
|
||||
`EXTERNAL_DEPENDENCIES (scripts/deps.ts). Either:\n` +
|
||||
` 1. Bundle this dependency by moving it to devDependencies, or\n` +
|
||||
` 2. Add it to EXTERNAL_DEPENDENCIES with a comment explaining why it can't be bundled`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for stale entries in the allowlist (not in dependencies or peerDependencies)
|
||||
// Note: we check against ALL dependencies here (including workspace ones) because
|
||||
// EXTERNAL_DEPENDENCIES is also used by the bundler to mark dependencies as external
|
||||
const allDeclaredDeps = [
|
||||
...getAllDependencies(packageJson.dependencies),
|
||||
...getAllDependencies(packageJson.peerDependencies),
|
||||
];
|
||||
|
||||
const staleDeps = externalDeps.filter(
|
||||
(dep) => !allDeclaredDeps.includes(dep)
|
||||
);
|
||||
|
||||
for (const dep of staleDeps) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has "${dep}" in EXTERNAL_DEPENDENCIES but it's not in ` +
|
||||
`dependencies or peerDependencies. Remove it from scripts/deps.ts.`
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an import specifier, returns the package name part.
|
||||
*
|
||||
* Examples:
|
||||
* "lodash" -> "lodash"
|
||||
* "lodash/fp" -> "lodash"
|
||||
* "@cloudflare/workers-utils" -> "@cloudflare/workers-utils"
|
||||
* "@cloudflare/workers-utils/test-helpers" -> "@cloudflare/workers-utils"
|
||||
* "vitest/runtime" -> "vitest"
|
||||
*/
|
||||
export function getPackageNameFromSpecifier(spec: string): string {
|
||||
if (spec.startsWith("@")) {
|
||||
const parts = spec.split("/");
|
||||
return parts.slice(0, 2).join("/");
|
||||
}
|
||||
return spec.split("/")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specifier refers to an external bare package import
|
||||
* (i.e. not a built-in, virtual module, or relative path).
|
||||
*/
|
||||
export function isBareSpecifier(spec: string): boolean {
|
||||
if (!spec || spec.startsWith(".") || spec.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
// Node built-ins, both prefixed (node:fs) and unprefixed (fs).
|
||||
if (isBuiltin(spec)) {
|
||||
return false;
|
||||
}
|
||||
// Known non-package protocols used inside bundled user code / templates.
|
||||
// These appear inside string literals in wrangler's shipped code templates,
|
||||
// not as real imports of an npm package.
|
||||
if (/^[a-z][a-z0-9+\-.]*:/.test(spec) && !spec.startsWith("@")) {
|
||||
return false;
|
||||
}
|
||||
// Virtual / runtime-injected modules — anything in ALL_CAPS_WITH_UNDERSCORES
|
||||
// is conventionally a build-time virtual module (e.g. __VITEST_POOL_WORKERS_DEFINES).
|
||||
if (/^__[A-Z][A-Z0-9_]*$/.test(spec)) {
|
||||
return false;
|
||||
}
|
||||
// Specifier must look like a valid npm package name: lowercase letters/digits,
|
||||
// hyphens, dots, underscores; optionally scoped (@scope/name). npm package
|
||||
// names cannot contain uppercase letters per the npm naming rules.
|
||||
const packageNameRe =
|
||||
/^(?:@[a-z0-9._~-]+\/)?[a-z0-9][a-z0-9._~-]*(?:\/[a-z0-9._~-]+)*$/;
|
||||
if (!packageNameRe.test(spec)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all bare-specifier imports from a JS/CJS source file.
|
||||
*
|
||||
* Handles:
|
||||
* import x from "pkg"
|
||||
* import { x } from "pkg"
|
||||
* import * as x from "pkg"
|
||||
* import "pkg"
|
||||
* export ... from "pkg"
|
||||
* require("pkg")
|
||||
* await import("pkg") (only when specifier is a string literal)
|
||||
*
|
||||
* Returns top-level package names (e.g. "vitest" for "vitest/runtime").
|
||||
*
|
||||
* Uses esbuild's parser (via a `bundle: true` build with everything marked
|
||||
* external) so that specifiers found inside string literals, template
|
||||
* literals, comments, etc. are correctly ignored. `bundle: true` is required
|
||||
* to surface `require()` calls in the metafile — `bundle: false` only
|
||||
* reports ESM `import` statements. The `externalize-all` plugin short-
|
||||
* circuits resolution so esbuild never has to find the imports on disk.
|
||||
*/
|
||||
export async function extractBareImports(
|
||||
content: string
|
||||
): Promise<Set<string>> {
|
||||
const imports = new Set<string>();
|
||||
|
||||
const result = await esbuild.build({
|
||||
stdin: { contents: content, loader: "js" },
|
||||
metafile: true,
|
||||
bundle: true,
|
||||
write: false,
|
||||
logLevel: "silent",
|
||||
platform: "neutral",
|
||||
plugins: [
|
||||
{
|
||||
name: "externalize-all",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, (args) => {
|
||||
if (args.kind === "entry-point") {
|
||||
return undefined;
|
||||
}
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const input of Object.values(result.metafile.inputs)) {
|
||||
for (const imp of input.imports) {
|
||||
if (isBareSpecifier(imp.path)) {
|
||||
imports.add(getPackageNameFromSpecifier(imp.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every path string referenced by the package's main/module/exports/bin
|
||||
* fields. These are the entry points that Node.js will actually load when the
|
||||
* package is imported or executed — and thus the surface that needs to have
|
||||
* all of its imports resolvable from `dependencies`/`peerDependencies`.
|
||||
*
|
||||
* Other entries in `files` (e.g. user-facing scaffolding templates) are
|
||||
* intentionally excluded because they aren't loaded by the package itself.
|
||||
*/
|
||||
export function getEntryPointPaths(packageJson: PackageJSON): string[] {
|
||||
const paths = new Set<string>();
|
||||
if (packageJson.main) {
|
||||
paths.add(packageJson.main);
|
||||
}
|
||||
if (packageJson.module) {
|
||||
paths.add(packageJson.module);
|
||||
}
|
||||
walkExportValue(packageJson.exports, paths);
|
||||
if (typeof packageJson.bin === "string") {
|
||||
paths.add(packageJson.bin);
|
||||
} else if (packageJson.bin && typeof packageJson.bin === "object") {
|
||||
for (const v of Object.values(packageJson.bin)) {
|
||||
if (typeof v === "string") {
|
||||
paths.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function walkExportValue(value: unknown, paths: Set<string>): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
paths.add(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
walkExportValue(v, paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const v of Object.values(value as Record<string, unknown>)) {
|
||||
walkExportValue(v, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the package's runtime entry points (and any sibling files in the same
|
||||
* output directories — to cover code-split chunks) and returns the union of
|
||||
* all bare-specifier imports found across them.
|
||||
*/
|
||||
export async function scanDistForExternalImports(
|
||||
packageDir: string,
|
||||
packageJson: PackageJSON
|
||||
): Promise<Set<string>> {
|
||||
const imports = new Set<string>();
|
||||
const entryPaths = getEntryPointPaths(packageJson);
|
||||
|
||||
// Build the set of patterns to scan. For each entry-point path that points
|
||||
// at a JS file, also scan its containing directory recursively to cover
|
||||
// code-split chunks. (E.g. workers-utils' dist/index.mjs re-exports from
|
||||
// dist/chunk-*.mjs, which we want to validate too.)
|
||||
const patterns = new Set<string>();
|
||||
for (const rawPath of entryPaths) {
|
||||
const cleaned = rawPath.replace(/^\.\//, "");
|
||||
if (!/\.(mjs|cjs|js)$/.test(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
const absPath = resolve(packageDir, cleaned);
|
||||
if (!existsSync(absPath)) {
|
||||
continue;
|
||||
}
|
||||
const containingDir = dirname(cleaned);
|
||||
if (containingDir && containingDir !== ".") {
|
||||
patterns.add(`${containingDir}/**/*.{mjs,cjs,js}`);
|
||||
} else {
|
||||
patterns.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
if (patterns.size === 0) {
|
||||
return imports;
|
||||
}
|
||||
|
||||
const matched = await glob([...patterns], {
|
||||
cwd: packageDir,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
for (const file of matched) {
|
||||
if (file.endsWith(".map") || file.endsWith(".d.ts")) {
|
||||
continue;
|
||||
}
|
||||
const content = readFileSync(file, "utf-8");
|
||||
for (const imp of await extractBareImports(content)) {
|
||||
imports.add(imp);
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that every bare-specifier import found in a package's published
|
||||
* files is declared as either a `dependency` or `peerDependency`.
|
||||
*
|
||||
* Catches drift between bundler config `external` lists and `package.json` —
|
||||
* for example, a devDependency that's incorrectly marked external in the
|
||||
* bundler config would leave the published bundle importing an undeclared
|
||||
* runtime dependency.
|
||||
*/
|
||||
export function validateDistImports(
|
||||
packageName: string,
|
||||
packageJson: PackageJSON,
|
||||
importedPackages: Set<string>,
|
||||
ignoredImports: string[] = []
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
const declaredRuntimeDeps = new Set([
|
||||
...getAllDependencies(packageJson.dependencies),
|
||||
...getAllDependencies(packageJson.peerDependencies),
|
||||
]);
|
||||
const devDeps = new Set(getAllDependencies(packageJson.devDependencies));
|
||||
const ignored = new Set(ignoredImports);
|
||||
|
||||
for (const imp of importedPackages) {
|
||||
// A package importing itself by name is always fine — it's a
|
||||
// self-reference inside the bundle (e.g. wrangler's bundled cli.js
|
||||
// contains code that references "wrangler" as a string literal).
|
||||
if (imp === packageName) {
|
||||
continue;
|
||||
}
|
||||
if (declaredRuntimeDeps.has(imp)) {
|
||||
continue;
|
||||
}
|
||||
if (ignored.has(imp)) {
|
||||
continue;
|
||||
}
|
||||
if (devDeps.has(imp)) {
|
||||
errors.push(
|
||||
`Package "${packageName}" imports "${imp}" in its published files, but ` +
|
||||
`"${imp}" is only a devDependency. Either:\n` +
|
||||
` 1. Move "${imp}" to dependencies (and add to scripts/deps.ts if appropriate), or\n` +
|
||||
` 2. Bundle "${imp}" by removing it from the bundler config's "external" list, or\n` +
|
||||
` 3. Add "${imp}" to IGNORED_DIST_IMPORTS in scripts/deps.ts if it's a legitimate optional/try-catch import.`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`Package "${packageName}" imports "${imp}" in its published files, but ` +
|
||||
`"${imp}" is not declared in dependencies or peerDependencies. ` +
|
||||
`Add it to package.json or to IGNORED_DIST_IMPORTS in scripts/deps.ts.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all packages properly declare their external dependencies
|
||||
*/
|
||||
export async function checkPackageDependencies(): Promise<string[]> {
|
||||
const packages = await getPublicPackages();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { dir, packageJson } of packages) {
|
||||
const packageName = packageJson.name;
|
||||
const relativePath = dir.split("/packages/")[1];
|
||||
console.log(`- ${packageName}`);
|
||||
|
||||
const externalDeps = loadExternalDependencies(dir);
|
||||
const packageErrors = validatePackageDependencies(
|
||||
packageName,
|
||||
relativePath,
|
||||
packageJson,
|
||||
externalDeps
|
||||
);
|
||||
errors.push(...packageErrors);
|
||||
|
||||
// Scan the published runtime files (main/module/exports/bin) to catch
|
||||
// drift between bundler `external` lists and `package.json`. This
|
||||
// catches devDeps incorrectly marked as external — which would leave
|
||||
// the published bundle with unresolvable imports for end users.
|
||||
try {
|
||||
const importedPackages = await scanDistForExternalImports(
|
||||
dir,
|
||||
packageJson
|
||||
);
|
||||
const ignoredImports = loadIgnoredDistImports(dir);
|
||||
const distErrors = validateDistImports(
|
||||
packageName,
|
||||
packageJson,
|
||||
importedPackages,
|
||||
ignoredImports
|
||||
);
|
||||
errors.push(...distErrors);
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`Package "${packageName}" dist scan failed: ${e instanceof Error ? e.message : String(e)}.\n` +
|
||||
` Make sure the package is built before running this check.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Validates that all non-bundled dependencies of published packages are pinned
|
||||
* to exact versions.
|
||||
*
|
||||
* Anything a published package does not bundle is installed into the consumer's
|
||||
* dependency tree at install time. If those specifiers are ranges, a malicious
|
||||
* or broken upstream release can be pulled into users' installs without us
|
||||
* having a chance to vet it. Pinning to exact versions locks down exactly what
|
||||
* ships. (devDependencies are intentionally NOT checked: npm never installs a
|
||||
* published package's devDependencies into a consumer's tree, so their
|
||||
* specifiers cannot affect users.)
|
||||
*
|
||||
* The check has two halves:
|
||||
*
|
||||
* 1. Catalog pinning — every entry in the `catalog:` block of
|
||||
* pnpm-workspace.yaml must be an exact version. Because the catalog is
|
||||
* guaranteed pinned, any `catalog:` reference elsewhere is trusted and
|
||||
* doesn't need to be resolved per-package.
|
||||
*
|
||||
* 2. Package pinning — in every published (non-private) package, every
|
||||
* `dependencies` and `optionalDependencies` entry must be an exact
|
||||
* version. Specifiers using `workspace:`, `catalog:`, `npm:`, `link:` or
|
||||
* `file:` are skipped (the catalog ones are covered by half 1; workspace
|
||||
* ones are released atomically with the monorepo). `peerDependencies` are
|
||||
* excluded because ranges there are intentional and generally necessary.
|
||||
*/
|
||||
|
||||
import { loadCatalog } from "./validate-catalog-usage";
|
||||
import {
|
||||
getPublicPackages,
|
||||
type PackageJSON,
|
||||
} from "./validate-package-dependencies";
|
||||
|
||||
/**
|
||||
* Matches an exact semantic version: MAJOR.MINOR.PATCH with optional
|
||||
* `-prerelease` and `+build` metadata (e.g. "1.2.3", "4.1.0-beta.10",
|
||||
* "2.0.0-rc.24"). Rejects ranges (^, ~, >, <, ||, hyphen ranges), wildcards
|
||||
* (*, x), and partial versions.
|
||||
*/
|
||||
const PINNED_VERSION_RE =
|
||||
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
||||
|
||||
/**
|
||||
* Specifier prefixes that are not literal version pins. These are validated
|
||||
* elsewhere (catalog entries by `validateCatalogPins`) or are released
|
||||
* atomically with the monorepo (workspace), so they are skipped here.
|
||||
*/
|
||||
const NON_LITERAL_PREFIXES = [
|
||||
"workspace:",
|
||||
"catalog:",
|
||||
"npm:",
|
||||
"link:",
|
||||
"file:",
|
||||
];
|
||||
|
||||
/**
|
||||
* Catalog entries that are deliberately left as ranges.
|
||||
*
|
||||
* `@cloudflare/workers-types` is consumed as an (optional) peerDependency via
|
||||
* `catalog:default`. A range is intentional there so consumers aren't forced
|
||||
* onto a single exact version of the types package.
|
||||
*/
|
||||
export const CATALOG_PIN_EXCEPTIONS = new Set(["@cloudflare/workers-types"]);
|
||||
|
||||
export function isPinnedVersion(version: string): boolean {
|
||||
return PINNED_VERSION_RE.test(version);
|
||||
}
|
||||
|
||||
function isNonLiteralSpecifier(version: string): boolean {
|
||||
return NON_LITERAL_PREFIXES.some((prefix) => version.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that every catalog entry is pinned to an exact version (except
|
||||
* deliberately-ranged entries in the exceptions allowlist).
|
||||
*/
|
||||
export function validateCatalogPins(
|
||||
catalog: Map<string, string>,
|
||||
exceptions: Set<string> = CATALOG_PIN_EXCEPTIONS
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [name, version] of catalog) {
|
||||
if (exceptions.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (!isPinnedVersion(version)) {
|
||||
errors.push(
|
||||
`Catalog entry "${name}" uses "${version}" but must be pinned to an exact ` +
|
||||
`version in pnpm-workspace.yaml (e.g. "1.2.3", not a range). Catalog entries ` +
|
||||
`can be consumed as non-bundled dependencies, so their versions must be locked down.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a single package's non-bundled dependencies are pinned.
|
||||
* Returns an array of error messages (empty if valid).
|
||||
*/
|
||||
export function validatePackagePins(
|
||||
packageName: string,
|
||||
relativePath: string,
|
||||
packageJson: PackageJSON
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const sections = ["dependencies", "optionalDependencies"] as const;
|
||||
|
||||
for (const section of sections) {
|
||||
const deps = packageJson[section];
|
||||
if (!deps) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (isNonLiteralSpecifier(version)) {
|
||||
continue;
|
||||
}
|
||||
if (!isPinnedVersion(version)) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has ${section} "${name}" set to "${version}" ` +
|
||||
`(packages/${relativePath}/package.json), but non-bundled dependencies must be ` +
|
||||
`pinned to an exact version (e.g. "1.2.3", not a range). If it should be ` +
|
||||
`sourced from the pnpm catalog, use "catalog:default" instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all catalog entries and all published packages' non-bundled
|
||||
* dependencies are pinned to exact versions.
|
||||
*/
|
||||
export async function checkPinnedDependencies(): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
|
||||
console.log("- catalog (pnpm-workspace.yaml)");
|
||||
errors.push(...validateCatalogPins(loadCatalog()));
|
||||
|
||||
const packages = await getPublicPackages();
|
||||
for (const { dir, packageJson } of packages) {
|
||||
const relativePath = dir.split("/packages/")[1];
|
||||
console.log(`- ${packageJson.name}`);
|
||||
errors.push(
|
||||
...validatePackagePins(packageJson.name, relativePath, packageJson)
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking dependency version pinning");
|
||||
checkPinnedDependencies()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Dependency pinning checks:" +
|
||||
errors.map((e) => `\n- ${e}`).join("")
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars -- this script reads CI environment variables */
|
||||
if (require.main === module) {
|
||||
const errors = validateDescription(
|
||||
process.env.TITLE as string,
|
||||
process.env.BODY as string,
|
||||
process.env.LABELS as string,
|
||||
process.env.FILES as string
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
console.error("Validation errors in PR description:");
|
||||
for (const error of errors) {
|
||||
console.error("- ", error);
|
||||
}
|
||||
if (process.env.DRAFT !== "true") {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("These errors must be fixed before you can merge this PR.");
|
||||
console.error(
|
||||
"When you mark this PR as ready for review the CI check will start failing."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDescription(
|
||||
title: string,
|
||||
body: string,
|
||||
labels: string,
|
||||
changedFilesJson: string
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
console.log("PR:", title);
|
||||
|
||||
const parsedLabels = JSON.parse(labels) as string[];
|
||||
|
||||
if (parsedLabels.includes("ci:skip-pr-description-validation")) {
|
||||
console.log(
|
||||
"Skipping validation because the `ci:skip-pr-description-validation` label has been applied"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
/- \[x\] Tests included\/updated/i.test(body) ||
|
||||
/- \[x\] Automated tests not possible - manual testing has been completed as follows:\s*.+/i.test(
|
||||
body
|
||||
) ||
|
||||
/- \[x\] Additional testing not necessary because:\s*.+/i.test(body)
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR must include tests, or provide justification for why no tests are required in the PR description and apply the `ci:no-tests` label"
|
||||
);
|
||||
}
|
||||
|
||||
const changedFiles = JSON.parse(changedFilesJson) as string[];
|
||||
const changesetIncluded = changedFiles.some((f) =>
|
||||
f.startsWith(".changeset/")
|
||||
);
|
||||
|
||||
if (
|
||||
!changesetIncluded &&
|
||||
!parsedLabels.includes("ci:no-changeset-required")
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR doesn't include a changeset. Either include one (following the instructions in CONTRIBUTING.md) or add the 'ci:no-changeset-required' label to bypass this check. Most PRs should have a changeset, so only bypass this check if you're sure that your change doesn't need one: see https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md#changesets for more details."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
/- \[x\] Cloudflare docs PR\(s\): https:\/\/(github\.com\/cloudflare\/cloudflare-docs\/(pull|issues)\/\d+|developers\.cloudflare\.com\/.*)/i.test(
|
||||
body
|
||||
) || /- \[x\] Documentation not necessary because: .+/i.test(body)
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR must include documentation (in the form of a link to a Cloudflare Docs issue or PR), or provide justification for why no documentation is required"
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
import type { PackageJSON } from "./validate-fixtures";
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking package names");
|
||||
checkPrivatePackageScopes()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Package names checks:" + errors.map((e) => `\n- ${e}`)
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPrivatePackageJsons() {
|
||||
const packageJsonPaths = await glob("**/package.json", {
|
||||
cwd: resolve(__dirname, "../../"),
|
||||
ignore: [
|
||||
// We are not interested in dependencies
|
||||
"**/node_modules/**",
|
||||
// The package.jsons in the fixtures directory use the `@fixture/` scope
|
||||
// TODO: consider if we should use the `@cloudflare/` scope instead
|
||||
"fixtures/**",
|
||||
// C3 template package.jsons have placeholder names that are populated by C3 during the app creation
|
||||
"packages/create-cloudflare/templates/**",
|
||||
// The package.jsons in the vite-plugin playground use the `@playground/` scope
|
||||
// TODO: consider if we should use the `@cloudflare/` scope instead
|
||||
"packages/vite-plugin-cloudflare/playground/**",
|
||||
// We are not interested in vendor sub-packages
|
||||
"vendor/**",
|
||||
],
|
||||
});
|
||||
return (
|
||||
packageJsonPaths
|
||||
.map((packageJson) => {
|
||||
const resolved = resolve(packageJson);
|
||||
return {
|
||||
dir: dirname(resolved),
|
||||
packageJson: JSON.parse(
|
||||
readFileSync(packageJson, "utf-8")
|
||||
) as PackageJSON,
|
||||
};
|
||||
})
|
||||
// We are only interested in private packages
|
||||
.filter(({ packageJson }) => packageJson.private)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that private packages are all scoped to `@cloudflare/`.
|
||||
*/
|
||||
async function checkPrivatePackageScopes(): Promise<string[]> {
|
||||
const relevantPackageJsons = await getPrivatePackageJsons();
|
||||
const errors: string[] = [];
|
||||
for (const { dir, packageJson } of relevantPackageJsons) {
|
||||
console.log(`- ${dir}`);
|
||||
|
||||
if (!packageJson.name.startsWith("@cloudflare/")) {
|
||||
errors.push(
|
||||
`Private package in directory "${dir}" has a name that does not start with "@cloudflare/". Please rename it to start with "@cloudflare/".`
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user