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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:11 +08:00
commit 70bf21e064
5213 changed files with 731895 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Continuous Integration (CI) tools
Tools for helping with CI
- `deployments/deploy-non-npm-packages()` - Deploy all packages that had updates but are not deployed to npm automatically by the changesets tooling.
This is used by the changesets.yml GitHub workflow to deploy non-npm packages (e.g. Workers and Pages projects).
- `deployments/ensure-fixtures-are-not-deployable.ts` - 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.
Used by test-and-check.yml GitHub Action workflows, as part of the `check` npm script.
- `deployments/validate-changesets.ts` - Validate that changesets are formatted correctly.
Used by the changesets.yml and test-and-check.yml GitHub Action workflows.
- `deployments/validate-pinned-dependencies.ts` - Ensures all non-bundled dependencies of published packages (and all pnpm catalog entries) are pinned to exact versions.
Used by the test-and-check.yml GitHub Action workflow, as part of the `check` npm script (`pnpm check:pinned-deps`).
- `dependabot/generate-dependabot-pr-changesets.ts` - Generates and commits a changeset for a Dependabot PR.
Used by the c3-dependabot-versioning-prs.yml and miniflare-dependabot-versioning-prs.yml GitHub Action workflows.
- `e2e/e2eCleanup.ts` - Ensures that any orphaned Pages projects and Workers that were left behind from e2e tests are deleted.
- `e2e/runIndividualE2EFiles.ts` - Used to shard the e2e tests into separately cache-able Turbo runs, which helps with flakey tests.
- `test/run-test-file.ts` - Used by in VS Code configuration to launch a debug session to run a single test file.
- `test-workers/` - Contains worker definitions used by CI tests. Each worker has its own subdirectory with `index.js` and `wrangler.jsonc` files. See `test-workers/README.md` for details.
+79
View File
@@ -0,0 +1,79 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "vitest";
import { clean } from "../clean";
describe("clean()", () => {
let tmpDir: string;
beforeEach(() => {
// Use realpathSync because the temporary path can point to a symlink rather than the actual path.
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "clean-tests")));
});
afterEach(() => {
if (existsSync(tmpDir)) {
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- test cleanup
rmSync(tmpDir, { recursive: true });
}
});
it("should remove a single directory", ({ expect }) => {
const dir = join(tmpDir, "to-remove");
mkdirSync(dir);
writeFileSync(join(dir, "file.txt"), "content");
clean([dir]);
expect(existsSync(dir)).toBe(false);
});
it("should remove multiple directories", ({ expect }) => {
const dir1 = join(tmpDir, "dir1");
const dir2 = join(tmpDir, "dir2");
mkdirSync(dir1);
mkdirSync(dir2);
clean([dir1, dir2]);
expect(existsSync(dir1)).toBe(false);
expect(existsSync(dir2)).toBe(false);
});
it("should silently handle non-existent paths", ({ expect }) => {
const nonExistent = join(tmpDir, "does-not-exist");
expect(() => clean([nonExistent])).not.toThrow();
});
it("should remove nested directories recursively", ({ expect }) => {
const nested = join(tmpDir, "a", "b", "c");
mkdirSync(nested, { recursive: true });
writeFileSync(join(nested, "deep.txt"), "deep content");
clean([join(tmpDir, "a")]);
expect(existsSync(join(tmpDir, "a"))).toBe(false);
});
it("should remove a single file", ({ expect }) => {
const file = join(tmpDir, "file.txt");
writeFileSync(file, "content");
clean([file]);
expect(existsSync(file)).toBe(false);
});
it("should handle empty paths array", ({ expect }) => {
expect(() => clean([])).not.toThrow();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { rmSync } from "node:fs";
import { resolve } from "node:path";
/**
* Cross-platform recursive directory/file removal utility.
* Silently handles non-existent paths.
*/
export function clean(paths: string[]): void {
for (const p of paths) {
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- clean tool intentionally removes build artifacts
rmSync(resolve(p), { recursive: true, force: true });
}
}
if (require.main === module) {
clean(process.argv.slice(2));
}
@@ -0,0 +1,399 @@
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import dedent from "ts-dedent";
import { describe, it, vitest } from "vitest";
import {
commitAndPush,
generateChangesetHeader,
generateCommitMessage,
getDependencyChanges,
getPackageJsonDiff,
parseDiffForChanges,
writeChangeSet,
} from "../generate-dependabot-pr-changesets";
import type { Mock } from "vitest";
vitest.mock("node:child_process", async () => {
return {
spawnSync: vitest.fn(),
};
});
vitest.mock("node:fs", async () => {
return {
writeFileSync: vitest.fn(),
};
});
describe("getPackageJsonDiff()", () => {
it("should call spawnSync to the appropriate git diff command", ({
expect,
}) => {
(spawnSync as Mock).mockReturnValue({ output: [] });
getPackageJsonDiff("/path/to/package.json");
expect(spawnSync).toHaveBeenCalledOnce();
expect((spawnSync as Mock).mock.lastCall).toMatchInlineSnapshot(`
[
"git",
[
"diff",
"HEAD~1",
"/path/to/package.json",
],
{
"encoding": "utf-8",
},
]
`);
});
it("should split the output into lines", ({ expect }) => {
(spawnSync as Mock).mockReturnValue({
output: ["line 1", "line 2\nline 3", "line 4"],
});
const diffLines = getPackageJsonDiff("/path/to/package.json");
expect(diffLines).toMatchInlineSnapshot(`
[
"line 1",
"line 2",
"line 3",
"line 4",
]
`);
});
});
describe("parseDiffForChanges()", () => {
it("should return a map containing the changes for each package", ({
expect,
}) => {
const changes = parseDiffForChanges([
`- "package": "^0.0.1"`,
`+ "package": "^0.0.2",`,
`- "@namespace/package": "1.3.4"`,
`+ "@namespace/package": "1.4.5",`,
]);
expect(changes).toEqual(
new Map([
["package", { from: "^0.0.1", to: "^0.0.2" }],
["@namespace/package", { from: "1.3.4", to: "1.4.5" }],
])
);
});
it("should ignore lines that do not match a change", ({ expect }) => {
const changes = parseDiffForChanges(["", undefined, "random text"]);
expect(changes.size).toBe(0);
});
it("should capture both quoted and unquoted pnpm catalog entries", ({
expect,
}) => {
const changes = parseDiffForChanges([
// A literal dependency in a package.json (quoted, JSON key)
`- "workerd": "1.20260702.1",`,
`+ "workerd": "1.20260706.1",`,
// A quoted catalog entry in pnpm-workspace.yaml (caret preserved)
`- "@cloudflare/workers-types": "^4.20260702.1"`,
`+ "@cloudflare/workers-types": "^5.20260706.1"`,
// workerd also appears in pnpm-workspace.yaml, but as an *unquoted*
// YAML key. The catalog is the source of truth and always carries the
// same version as the package.json pin, so capturing it here merges
// into the same map entry rather than conflicting.
`- workerd: "1.20260702.1"`,
`+ workerd: "1.20260706.1"`,
]);
expect(changes).toEqual(
new Map([
[
"workerd",
{
from: "1.20260702.1",
to: "1.20260706.1",
},
],
[
"@cloudflare/workers-types",
{
from: "^4.20260702.1",
to: "^5.20260706.1",
},
],
])
);
});
it("should capture an unquoted catalog key even when no package.json bumps it", ({
expect,
}) => {
// When Dependabot bumps workerd only in the pnpm-workspace.yaml catalog
// (an unquoted YAML key), leaving the package.json pins untouched, workerd
// must still make it into the changeset.
const changes = parseDiffForChanges([
`- "@cloudflare/workers-types": "^5.20260708.1"`,
`+ "@cloudflare/workers-types": "^5.20260710.1"`,
`- workerd: "1.20260708.1"`,
`+ workerd: "1.20260710.1"`,
]);
expect(changes).toEqual(
new Map([
[
"@cloudflare/workers-types",
{
from: "^5.20260708.1",
to: "^5.20260710.1",
},
],
[
"workerd",
{
from: "1.20260708.1",
to: "1.20260710.1",
},
],
])
);
});
});
describe("getDependencyChanges()", () => {
it("should diff every provided path and merge the changes", ({ expect }) => {
(spawnSync as Mock).mockClear();
(spawnSync as Mock).mockImplementation((_command, args) => {
const path: string = args[2];
if (path.endsWith("package.json")) {
return {
output: [
`- "workerd": "1.20260702.1",`,
`+ "workerd": "1.20260706.1",`,
],
};
}
if (path.endsWith("pnpm-workspace.yaml")) {
return {
output: [
`- "@cloudflare/workers-types": "^4.20260702.1"`,
`+ "@cloudflare/workers-types": "^5.20260706.1"`,
],
};
}
return {
output: [],
};
});
const changes = getDependencyChanges([
"packages/miniflare/package.json",
"pnpm-workspace.yaml",
]);
expect(spawnSync).toHaveBeenCalledTimes(2);
expect(changes).toEqual(
new Map([
[
"workerd",
{
from: "1.20260702.1",
to: "1.20260706.1",
},
],
[
"@cloudflare/workers-types",
{
from: "^4.20260702.1",
to: "^5.20260706.1",
},
],
])
);
});
});
describe("generateChangesetHeader()", () => {
it("should return a header with a single package and 'patch' version bump", ({
expect,
}) => {
const header = generateChangesetHeader(["package-name"]);
expect(header).toMatchInlineSnapshot(`
"---
"package-name": patch
---"
`);
});
it("should return a header with multiple packages and 'patch' version bump", ({
expect,
}) => {
const header = generateChangesetHeader(["package-name", "another-package"]);
expect(header).toMatchInlineSnapshot(`
"---
"package-name": patch
"another-package": patch
---"
`);
});
});
describe("generateCommitMessage()", () => {
it("should return a commit message about a single changed package", ({
expect,
}) => {
const message = generateCommitMessage(["@namespace/package"], new Map());
expect(message).toMatchInlineSnapshot(`
"Update dependencies of "@namespace/package"
The following dependency versions have been updated:
| Dependency | From | To |
| ---------- | ---- | -- |"
`);
});
it("should return a commit message about multiple packages", ({ expect }) => {
const message = generateCommitMessage(
["@namespace/package", "another-package"],
new Map()
);
expect(message).toMatchInlineSnapshot(`
"Update dependencies of "@namespace/package", "another-package"
The following dependency versions have been updated:
| Dependency | From | To |
| ---------- | ---- | -- |"
`);
});
it("should return a commit message containing a row for each change", ({
expect,
}) => {
const message = generateCommitMessage(
["package-name"],
new Map([
["some-package", { from: "^0.0.1", to: "^0.0.2" }],
["@namespace/some-package", { from: "1.3.4", to: "1.4.5" }],
])
);
expect(message).toMatchInlineSnapshot(`
"Update dependencies of "package-name"
The following dependency versions have been updated:
| Dependency | From | To |
| ----------------------- | ------ | ------ |
| some-package | ^0.0.1 | ^0.0.2 |
| @namespace/some-package | 1.3.4 | 1.4.5 |"
`);
});
});
describe("writeChangeSet()", () => {
it("should call writeFileSync with appropriate changeset path and body", ({
expect,
}) => {
const header = dedent`
---
"package-name": patch
---`;
const body = dedent`
chore: update dependencies of "@namespace/package" package
The following dependency versions have been updated:
| Dependency | From | To |
| ----------------------- | ------ | ------ |
| some-package | ^0.0.1 | ^0.0.2 |
| @namespace/some-package | 1.3.4 | 1.4.5 |
`;
writeChangeSet("some-prefix", "1234", header, body);
expect(writeFileSync).toHaveBeenCalledOnce();
expect((writeFileSync as Mock).mock.lastCall?.[0]).toMatchInlineSnapshot(
`".changeset/some-prefix-1234.md"`
);
expect((writeFileSync as Mock).mock.lastCall?.[1]).toMatchInlineSnapshot(`
"---
"package-name": patch
---
chore: update dependencies of "@namespace/package" package
The following dependency versions have been updated:
| Dependency | From | To |
| ----------------------- | ------ | ------ |
| some-package | ^0.0.1 | ^0.0.2 |
| @namespace/some-package | 1.3.4 | 1.4.5 |
"
`);
});
});
describe("commitAndPush()", () => {
it("should call spawnSync with appropriate git commands", ({ expect }) => {
(spawnSync as Mock).mockClear();
(spawnSync as Mock).mockReturnValue({ output: [] });
const commitMessage = dedent`
chore: update dependencies of "@namespace/package" package
The following dependency versions have been updated:
| Dependency | From | To |
| ----------------------- | ------ | ------ |
| some-package | ^0.0.1 | ^0.0.2 |
| @namespace/some-package | 1.3.4 | 1.4.5 |`;
commitAndPush(commitMessage);
expect(spawnSync).toHaveBeenCalledTimes(3);
expect((spawnSync as Mock).mock.calls[0]).toMatchInlineSnapshot(`
[
"git",
[
"add",
".changeset",
],
{
"encoding": "utf-8",
},
]
`);
expect((spawnSync as Mock).mock.calls[1]).toMatchInlineSnapshot(`
[
"git",
[
"commit",
"-m",
"chore: update dependencies of "@namespace/package" package
The following dependency versions have been updated:
| Dependency | From | To |
| ----------------------- | ------ | ------ |
| some-package | ^0.0.1 | ^0.0.2 |
| @namespace/some-package | 1.3.4 | 1.4.5 |",
],
{
"encoding": "utf-8",
},
]
`);
expect((spawnSync as Mock).mock.calls[2]).toMatchInlineSnapshot(`
[
"git",
[
"push",
],
{
"encoding": "utf-8",
},
]
`);
});
it("should call error if any git command fails", ({ expect }) => {
(spawnSync as Mock).mockImplementation((git, args) => {
const error =
args[0] === "push" ? new Error("Failed to push") : undefined;
return { output: [], error };
});
expect(() =>
commitAndPush("commit message")
).toThrowErrorMatchingInlineSnapshot(`[Error: Failed to push]`);
});
});
@@ -0,0 +1,214 @@
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { argv } from "node:process";
import dedent from "ts-dedent";
if (require.main === module) {
try {
console.log(dedent`
Generate Dependabot Changeset
=============================
`);
main(processArgs());
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
} else {
console.error(`Error: ${e}`);
}
process.exit(1);
}
}
type Args = {
// PR number
prNumber: string;
// Comma-separated package names
packageNames: string;
// Path to package.json
packageJSONPath: string;
// Changeset file prefix
changesetPrefix: string;
};
function processArgs(): Args {
const args = [...argv];
if (args[0] === process.execPath) {
args.shift();
}
if (args[0] === __filename) {
args.shift();
}
if (args.length !== 4) {
throw new Error(dedent`
Incorrect arguments, please provide:
- PR: The number of the current Dependabot PR
- Packages: Coma-separated names of the workers-sdk packages whose dependencies are being updated
- PackageJSON: The path to the package JSON being updated by Dependabot
- Changeset prefix: The prefix to go on the front of the filename of the generated changeset.
`);
}
return {
prNumber: args[0],
packageNames: args[1],
packageJSONPath: args[2],
changesetPrefix: args[3],
};
}
function main({
prNumber,
packageNames,
packageJSONPath,
changesetPrefix,
}: Args): void {
// Also diff the pnpm catalog. Catalog-pinned deps (e.g.
// "@cloudflare/workers-types", referenced as "catalog:default" in
// `package.json`) have their real versions in `pnpm-workspace.yaml`, so
// Dependabot bumps them there rather than in `package.json`.
const changes = getDependencyChanges([
packageJSONPath,
"pnpm-workspace.yaml",
]);
if (changes.size === 0) {
console.warn(dedent`
WARN: No dependency changes detected for "${packageNames}".
`);
return;
}
const packages = packageNames.split(",").map((name: string) => name.trim());
const changesetHeader = generateChangesetHeader(packages);
const commitMessage = generateCommitMessage(packages, changes);
console.log(dedent`
INFO: Writing changeset with the following commit message
${commitMessage}`);
writeChangeSet(changesetPrefix, prNumber, changesetHeader, commitMessage);
commitAndPush(commitMessage);
}
export function getPackageJsonDiff(packageJSONPath: string): string[] {
return executeCommand("git", ["diff", "HEAD~1", packageJSONPath]);
}
export type Change = {
from: string;
to: string;
};
// Collects dependency changes across every provided file, merging them into a
// single map. We diff both the target package.json and the pnpm catalog because
// catalog-pinned deps are bumped in the catalog, not in the package.json.
export function getDependencyChanges(diffPaths: string[]): Map<string, Change> {
const diffLines = diffPaths.flatMap((path) =>
getPackageJsonDiff(resolve(path))
);
return parseDiffForChanges(diffLines);
}
export function parseDiffForChanges(
diffLines: (string | undefined)[]
): Map<string, Change> {
// Matches a dependency line in either a package.json diff (JSON, so the key
// is always quoted, e.g. `"workerd": "1.2.3"`) or a pnpm-workspace.yaml
// catalog diff (YAML, where the key may be unquoted, e.g. `workerd: "1.2.3"`).
// The quotes around the key are therefore optional.
const diffLineRegex = new RegExp(`^[+-]\\s*"?([^":]+?)"?:\\s"(.*)",?`);
const changes = new Map<string, Change>();
for (const line of diffLines) {
const match = line?.match(diffLineRegex);
if (match) {
const [matchedLine, name, version] = match;
const fromToProp = matchedLine.startsWith("+") ? "to" : "from";
const change = changes.get(name) ?? { from: "", to: "" };
change[fromToProp] = version;
changes.set(name, change);
}
}
return changes;
}
export function generateChangesetHeader(packages: string[]): string {
const lines = ["---"];
for (const packageName of packages) {
lines.push(`"${packageName}": patch`);
}
lines.push("---");
return lines.join("\n");
}
export function generateCommitMessage(
packages: string[],
changes: Map<string, Change>
): string {
const widths = [10, 4, 2];
for (const [name, { from, to }] of changes.entries()) {
if (from && to) {
widths[0] = Math.max(widths[0], name.length);
widths[1] = Math.max(widths[1], from.length);
widths[2] = Math.max(widths[2], to.length);
}
}
function padded(text: string, column: number): string {
return text + " ".repeat(widths[column] - text.length);
}
let table =
`\n| ${padded("Dependency", 0)} | ${padded("From", 1)} | ${padded(
"To",
2
)} |` +
`\n| ${"-".repeat(widths[0])} | ${"-".repeat(widths[1])} | ${"-".repeat(
widths[2]
)} |`;
for (const [name, { from, to }] of changes.entries()) {
if (!from || !to) {
console.warn(dedent`
WARN: Unexpected changes for package "${name}", from: "${from}", to: "${to}".
Could not determine upgrade versions.`);
} else {
table += `\n| ${padded(name, 0)} | ${padded(from, 1)} | ${padded(
to,
2
)} |`;
}
}
return dedent`
Update dependencies of ${packages.map((p: string) => `"${p}"`).join(", ")}
The following dependency versions have been updated:
${table}
`;
}
export function writeChangeSet(
changesetPrefix: string,
prNumber: string,
changesetHeader: string,
commitMessage: string
): void {
writeFileSync(
`.changeset/${changesetPrefix}-${prNumber}.md`,
changesetHeader + "\n\n" + commitMessage + "\n"
);
}
export function commitAndPush(commitMessage: string): void {
executeCommand("git", ["add", ".changeset"]);
executeCommand("git", ["commit", "-m", commitMessage]);
executeCommand("git", ["push"]);
}
function executeCommand(command: string, args: string[]): string[] {
const { output, error, status, stderr } = spawnSync(command, args, {
encoding: "utf-8",
});
if (status || error) {
throw error ?? new Error(stderr);
}
return output
.flatMap((chunk) => chunk?.split("\n"))
.filter((line) => line !== undefined);
}
@@ -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);
});
});
+52
View File
@@ -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;
}
}
+20
View File
@@ -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;
};
};
+131
View File
@@ -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);
});
}
+110
View File
@@ -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;
};
};
+78
View File
@@ -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;
}
+437
View File
@@ -0,0 +1,437 @@
import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from "undici";
import { afterEach, beforeEach, describe, it } from "vitest";
import {
deleteDatabase,
deleteKVNamespace,
deleteProject,
deleteR2Bucket,
deleteWorker,
listTmpDatabases,
listTmpE2EProjects,
listTmpE2EWorkers,
listTmpKVNamespaces,
listTmpR2Buckets,
} from "../common";
import type { Worker } from "../common";
const originalAccountID = process.env.CLOUDFLARE_ACCOUNT_ID;
const MOCK_CLOUDFLARE_ACCOUNT_ID = "mock-cloudflare-account";
const now = new Date();
const nowStr = now.toJSON();
const oldTime = new Date();
oldTime.setMinutes(oldTime.getMinutes() - 100);
const oldTimeStr = oldTime.toJSON();
const originalDispatcher = getGlobalDispatcher();
let agent: MockAgent;
beforeEach(() => {
// Mock out the undici Agent
agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);
process.env.CLOUDFLARE_ACCOUNT_ID = MOCK_CLOUDFLARE_ACCOUNT_ID;
});
afterEach(() => {
agent.assertNoPendingInterceptors();
setGlobalDispatcher(originalDispatcher);
process.env.CLOUDFLARE_ACCOUNT_ID = originalAccountID;
});
describe("listTmpE2EProjects()", () => {
it("makes paged REST requests and returns a filtered list of projects", async ({
expect,
}) => {
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/pages/projects`,
query: {
page: 1,
},
})
.reply(
200,
JSON.stringify({
result: [
{ name: "pages-project-1", created_on: nowStr },
{ name: "pages-project-2", created_on: oldTimeStr },
{ name: "tmp-e2e-project-1", created_on: nowStr },
{ name: "tmp-e2e-project-2", created_on: oldTimeStr },
{ name: "pages-project-3", created_on: nowStr },
{ name: "pages-project-4", created_on: oldTimeStr },
{ name: "tmp-e2e-project-3", created_on: nowStr },
{ name: "tmp-e2e-project-4", created_on: oldTimeStr },
{ name: "pages-project-5", created_on: nowStr },
{ name: "pages-project-6", created_on: oldTimeStr },
],
result_info: {
page: 1,
per_page: 10,
count: 10,
total_count: 12,
total_pages: 2,
},
})
);
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/pages/projects`,
query: {
page: 2,
},
})
.reply(
200,
JSON.stringify({
result: [
{ name: "tmp-e2e-project-5", created_on: nowStr },
{ name: "tmp-e2e-project-6", created_on: oldTimeStr },
],
result_info: {
page: 2,
per_page: 10,
count: 2,
total_count: 12,
total_pages: 2,
},
})
);
const result = await listTmpE2EProjects();
expect(result.map((p) => p.name)).toMatchInlineSnapshot(`
[
"tmp-e2e-project-2",
"tmp-e2e-project-4",
"tmp-e2e-project-6",
]
`);
});
});
describe("deleteProject()", () => {
// eslint-disable-next-line jest/expect-expect -- assertions are implicit via undici mock agent interceptors
it("makes a REST request to delete the given project", async () => {
const MOCK_PROJECT = "mock-pages-project";
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/pages/projects/${MOCK_PROJECT}`,
method: "DELETE",
})
.reply(200, JSON.stringify({ result: [] }));
await deleteProject(MOCK_PROJECT);
});
});
describe("listTmpKVNamespaces()", () => {
it("makes a REST request and returns a filtered list of kv namespaces", async ({
expect,
}) => {
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/storage/kv/namespaces`,
method: "GET",
query: {
page: 1,
},
})
.reply(
200,
JSON.stringify({
result: [
{ id: "kv-tmp-e2e", title: "kv-1" },
{ id: "kv-2", title: "kv-2" },
{ id: "tmp_e2e", title: "kv-3" },
{ id: "kv-4", title: "kv-4" },
{ id: "kv-5", title: "kv-5" },
{ id: "kv-6", title: "kv-6" },
{ id: "tmp_e2e_kv", title: "kv-7" },
{ id: "kv-8", title: "kv-8" },
{ id: "kv-9", title: "kv-9" },
{ id: "kv-10", title: "kv-10" },
...Array(90).fill({ id: "kv-10", title: "kv-10" }),
],
result_info: {
page: 1,
per_page: 10,
count: 10,
total_count: 11,
total_pages: 2,
},
})
);
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/storage/kv/namespaces`,
method: "GET",
query: {
page: 2,
},
})
.reply(
200,
JSON.stringify({
result: [{ id: "kv-tmp-e2e-11", title: "kv-11" }],
result_info: {
page: 2,
per_page: 10,
count: 2,
total_count: 12,
total_pages: 2,
},
})
);
const result = await listTmpKVNamespaces();
expect(result.map((p) => p.id)).toMatchInlineSnapshot(`[]`);
});
});
describe("deleteKVNamespace()", () => {
// eslint-disable-next-line jest/expect-expect -- assertions are implicit via undici mock agent interceptors
it("makes a REST request to delete the given project", async () => {
const MOCK_KV = "tmp_e2e_kv";
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/storage/kv/namespaces/${MOCK_KV}`,
method: "DELETE",
})
.reply(200, JSON.stringify({ result: [] }));
await deleteKVNamespace(MOCK_KV);
});
});
describe("listTmpDatabases()", () => {
it("makes a REST request and returns a filtered list of d1 databases", async ({
expect,
}) => {
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/d1/database`,
method: "GET",
query: {
page: 1,
},
})
.reply(
200,
JSON.stringify({
result: [
{ uuid: "1", name: "db-1", created_at: nowStr },
{ uuid: "2", name: "db-2", created_at: oldTimeStr },
{ uuid: "3", name: "tmp-e2e-db-1", created_at: nowStr },
{ uuid: "4", name: "tmp-e2e-db-2", created_at: oldTimeStr },
{ uuid: "5", name: "db-3", created_at: nowStr },
{ uuid: "6", name: "db-4", created_at: oldTimeStr },
{ uuid: "7", name: "tmp-e2e-db-3", created_at: nowStr },
{ uuid: "8", name: "tmp-e2e-db-4", created_at: oldTimeStr },
{ uuid: "9", name: "db-5", created_at: nowStr },
{ uuid: "10", name: "db-6", created_at: oldTimeStr },
...Array(90).fill({
uuid: "10",
name: "db-6",
created_at: oldTimeStr,
}),
],
result_info: {
page: 1,
per_page: 10,
count: 50,
total_count: 12,
total_pages: 2,
},
})
);
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/d1/database`,
method: "GET",
query: {
page: 2,
},
})
.reply(
200,
JSON.stringify({
result: [
{ uuid: "11", name: "db-11", created_at: nowStr },
{ uuid: "12", name: "db-12", created_at: oldTimeStr },
],
result_info: {
page: 2,
per_page: 10,
count: 2,
total_count: 12,
total_pages: 2,
},
})
);
const result = await listTmpDatabases();
expect(result.map((p) => p.name)).toMatchInlineSnapshot(`
[
"tmp-e2e-db-2",
"tmp-e2e-db-4",
]
`);
});
});
describe("deleteDatabase()", () => {
// eslint-disable-next-line jest/expect-expect -- assertions are implicit via undici mock agent interceptors
it("makes a REST request to delete the given project", async () => {
const MOCK_DB = "tmp-e2e-db";
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/d1/database/${MOCK_DB}`,
method: "DELETE",
})
.reply(200, JSON.stringify({ result: [] }));
await deleteDatabase(MOCK_DB);
});
});
describe("listTmpE2EWorkers()", () => {
it("makes a REST request and returns a filtered list of workers", async ({
expect,
}) => {
// First page has results
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/workers/scripts-search`,
query: { page: 1, per_page: 100 },
method: "GET",
})
.reply(
200,
JSON.stringify({
result: [
{ script_name: "wprker-1", created_on: nowStr },
{ script_name: "wprker-2", created_on: oldTimeStr },
{ script_name: "tmp-e2e-worker-1", created_on: nowStr },
{ script_name: "tmp-e2e-worker-2", created_on: oldTimeStr },
{ script_name: "wprker-3", created_on: nowStr },
{ script_name: "wprker-4", created_on: oldTimeStr },
{ script_name: "tmp-e2e-worker-3", created_on: nowStr },
{ script_name: "tmp-e2e-worker-4", created_on: oldTimeStr },
{ script_name: "wprker-5", created_on: nowStr },
{ script_name: "wprker-6", created_on: oldTimeStr },
] satisfies Worker[],
})
);
// Second page is empty
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/workers/scripts-search`,
query: { page: 2, per_page: 100 },
method: "GET",
})
.reply(
200,
JSON.stringify({
result: [],
})
);
const result = await listTmpE2EWorkers();
expect(result.map((p) => p.script_name)).toMatchInlineSnapshot(`
[
"wprker-2",
"tmp-e2e-worker-2",
"wprker-4",
"tmp-e2e-worker-4",
"wprker-6",
]
`);
});
});
describe("deleteWorker()", () => {
// eslint-disable-next-line jest/expect-expect -- assertions are implicit via undici mock agent interceptors
it("makes a REST request to delete the given project", async () => {
const MOCK_WORKER = "mock-worker";
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${MOCK_WORKER}`,
method: "DELETE",
})
.reply(200, JSON.stringify({ result: [] }));
await deleteWorker(MOCK_WORKER);
});
});
describe("listTmpR2Buckets()", () => {
it("makes a REST request and returns a filtered list of R2 buckets", async ({
expect,
}) => {
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/r2/buckets`,
method: "GET",
})
.reply(
200,
JSON.stringify({
result: {
buckets: [
{ name: "my-bucket-1", creation_date: nowStr },
{ name: "my-bucket-2", creation_date: oldTimeStr },
{
name: "tmp-e2e-abc123-next--workers-opennext-cache",
creation_date: nowStr,
},
{
name: "tmp-e2e-def456-next--workers-opennext-cache",
creation_date: oldTimeStr,
},
{ name: "tmp-e2e-project-1", creation_date: nowStr },
{ name: "tmp-e2e-project-2", creation_date: oldTimeStr },
],
},
success: true,
})
);
const result = await listTmpR2Buckets();
expect(result.map((b) => b.name)).toMatchInlineSnapshot(`
[
"tmp-e2e-def456-next--workers-opennext-cache",
"tmp-e2e-project-2",
]
`);
});
});
describe("deleteR2Bucket()", () => {
// eslint-disable-next-line jest/expect-expect -- assertions are implicit via undici mock agent interceptors
it("makes a REST request to delete the given R2 bucket", async () => {
const MOCK_BUCKET = "tmp-e2e-abc123-next--workers-opennext-cache";
agent
.get("https://api.cloudflare.com")
.intercept({
path: `/client/v4/accounts/${MOCK_CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${MOCK_BUCKET}`,
method: "DELETE",
})
.reply(200, JSON.stringify({ result: [] }));
await deleteR2Bucket(MOCK_BUCKET);
});
});
+500
View File
@@ -0,0 +1,500 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { fetch } from "undici";
import type { RequestInit, Response } from "undici";
type ApiErrorBody = {
errors: string[];
};
type ApiSuccessBody<T> = {
result: T[];
};
export type Project = {
name: string;
created_on: string;
};
export type R2Bucket = {
name: string;
creation_date: string;
};
export type ContainerApplication = {
created_at: string;
id: string;
name: string;
};
export type Worker = {
script_name: string;
created_on: string;
};
export interface KVNamespaceInfo {
id: string;
title: string;
}
export type Database = {
created_at: string;
uuid: string;
name: string;
};
export type HyperdriveConfig = {
id: string;
name: string;
created_on: string;
};
export type AgentMemoryNamespace = {
id: string;
name: string;
account_id: string;
created_at: string;
updated_at: string;
};
export type MTlsCertificateResponse = {
id: string;
name?: string;
ca: boolean;
certificates: string;
expires_on: string;
issuer: string;
serial_number: string;
signature: string;
uploaded_on: string;
};
class ApiError extends Error {
readonly status: number;
readonly statusText: string;
readonly text: Promise<string>;
constructor(
readonly url: string,
readonly init: RequestInit,
response: Response
) {
super();
this.status = response.status;
this.statusText = response.statusText;
this.text = response.text();
}
}
async function apiFetchResponse(
path: string,
init = { method: "GET" },
queryParams = {}
): Promise<Response> {
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}`;
let queryString = new URLSearchParams(queryParams).toString();
if (queryString) {
queryString = "?" + queryString;
}
const url = `${baseUrl}${path}${queryString}`;
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
},
});
if (!response.ok || response.status >= 400) {
throw new ApiError(url, init, response);
}
return response;
}
async function apiFetch<T>(
path: string,
method: string,
queryParams: Record<string, unknown> = {},
failSilently: boolean = false
): Promise<false | T> {
try {
const response = await apiFetchResponse(path, { method }, queryParams);
const json = (await response.json()) as ApiSuccessBody<T>;
return json.result as T;
} catch (e) {
if (!failSilently) {
if (e instanceof ApiError) {
console.error(e.url, e.init);
console.error(`(${e.status}) ${e.statusText}`);
const body = JSON.parse(await e.text) as ApiErrorBody;
console.error(body.errors);
} else {
console.error(e);
}
}
return false;
}
}
async function apiFetchAllPages<T>(
path: string,
queryParams = {}
): Promise<T[]> {
const result: T[] = [];
try {
let page = 1;
while (true) {
const response = await apiFetchResponse(
path,
{ method: "GET" },
{ page, per_page: 100, ...queryParams }
);
const json = (await response.json()) as ApiSuccessBody<T>;
if (json.result.length === 0) {
// We hit an empty page so we are done.
break;
}
result.push(...json.result);
page++;
}
} catch (e) {
if (e instanceof ApiError) {
console.error(e.url, e.init);
console.error(`(${e.status}) ${e.statusText}`);
const body = JSON.parse(await e.text) as ApiErrorBody;
console.error(body.errors);
} else {
console.error(e);
}
}
return result;
}
async function apiFetchList<T>(path: string, queryParams = {}): Promise<T[]> {
try {
let page = 1;
let totalCount = 0;
let result: unknown[] = [];
while (true) {
const response = await apiFetchResponse(
path,
{ method: "GET" },
{
page,
...queryParams,
}
);
if (!response || response.ok === false) {
return [];
}
const json = (await response.json()) as ApiSuccessBody<T>;
if ("result_info" in json && Array.isArray(json.result)) {
const result_info = json.result_info as {
page: number;
count: number;
total_count?: number;
total_pages?: number;
};
console.log(path, result_info);
result = [...result, ...json.result];
if (result_info.count === 0) {
return result as T[];
}
totalCount += result_info.count;
if (totalCount === result_info.total_count) {
return result as T[];
}
if (page === result_info.total_pages) {
return result as T[];
}
page = result_info.page + 1;
} else {
return json.result as T[];
}
}
} catch (e) {
if (e instanceof ApiError) {
console.error(e.url, e.init);
console.error(`(${e.status}) ${e.statusText}`);
const body = JSON.parse(await e.text) as ApiErrorBody;
console.error(body.errors);
} else {
console.error(e);
}
return [];
}
}
export const listTmpE2EProjects = async () => {
return (await apiFetchList<Project>(`/pages/projects`)).filter(
(p) =>
p.name.startsWith("tmp-e2e-") &&
// Projects are more than an hour old
Date.now() - new Date(p.created_on).valueOf() > 1000 * 60 * 60
);
};
export const deleteProject = async (project: string) => {
return await apiFetch(`/pages/projects/${project}`, "DELETE");
};
export const listTmpE2EWorkers = async () => {
const workers = await apiFetchAllPages<Worker>(`/workers/scripts-search`);
return workers.filter(
(p) =>
!p.script_name.startsWith("preserve-e2e-") &&
p.script_name !== "stratus-e2e-test-worker" &&
p.script_name !== "existing-script-test-do-not-delete" &&
// Workers are more than an hour old
Date.now() - new Date(p.created_on).valueOf() > 1000 * 60 * 60
);
};
export const deleteWorker = async (script_name: string) => {
return await apiFetch(`/workers/scripts/${script_name}`, "DELETE");
};
export const listTmpE2EContainerApplications = async () => {
const res = await apiFetchResponse(`/cloudchamber/applications`, {
method: "GET",
});
if (!res) {
// unreachable, but assert() is failing to pin down the type
throw res;
}
if (!res.ok) {
throw new Error(`${res.status}: ${await res.text()}`);
}
const apps = (await res.json()) as ContainerApplication[];
return apps.filter(
(app) =>
app.name.includes("e2e") &&
Date.now() - new Date(app.created_at).valueOf() > 1000 * 60 * 60
);
};
export const deleteContainerApplication = async (app: ContainerApplication) => {
return await apiFetchResponse(`/cloudchamber/applications/${app.id}`, {
method: "DELETE",
});
};
export const listTmpKVNamespaces = async () => {
return (await apiFetchList<KVNamespaceInfo>(`/storage/kv/namespaces`)).filter(
(kv) => {
const isTempE2E =
kv.title.includes("tmp-e2e") || kv.title.includes("tmp_e2e");
// Since KV namespaces don't have creation date metadata, we encode the date-time in the title
const creationDate = new Date(
Number(kv.title.match(/tmp[-_]e2e[-_]kv(\d+)-$/)?.[1] ?? 0)
);
// Temp KV namespaces that are more than an hour old (or any age if no date is found)
return isTempE2E && Date.now() - creationDate.valueOf() > 1000 * 60 * 60;
}
);
};
export const deleteKVNamespace = async (id: string) => {
return await apiFetch(
`/storage/kv/namespaces/${id}`,
"DELETE",
{},
/* failSilently */ true
);
};
export const listTmpR2Buckets = async () => {
const response = await apiFetchResponse(`/r2/buckets`, { method: "GET" });
if (!response || !response.ok) {
return [];
}
const json = (await response.json()) as {
result: { buckets: R2Bucket[] };
};
return json.result.buckets.filter(
(bucket) =>
bucket.name.startsWith("tmp-e2e-") &&
// Buckets are more than an hour old
Date.now() - new Date(bucket.creation_date).valueOf() > 1000 * 60 * 60
);
};
export const deleteR2Bucket = async (name: string) => {
return await apiFetch(
`/r2/buckets/${name}`,
"DELETE",
{},
/* failSilently */ true
);
};
export const listTmpDatabases = async () => {
return (await apiFetchList<Database>(`/d1/database`)).filter(
(db) =>
db.name.includes("tmp-e2e") && // Databases are more than an hour old
Date.now() - new Date(db.created_at).valueOf() > 1000 * 60 * 60
);
};
export const deleteDatabase = async (id: string) => {
return (await apiFetch(`/d1/database/${id}`, "DELETE")) !== false;
};
export const listHyperdriveConfigs = async () => {
return (await apiFetchList<HyperdriveConfig>(`/hyperdrive/configs`)).filter(
(config) =>
config.name.includes("tmp-e2e") && // Databases are more than an hour old
Date.now() - new Date(config.created_on).valueOf() > 1000 * 60 * 60
);
};
export const deleteHyperdriveConfig = async (id: string) => {
return await apiFetch(`/hyperdrive/configs/${id}`, "DELETE");
};
export const listCertificates = async () => {
return (
await apiFetchList<MTlsCertificateResponse>(`/mtls_certificates`)
).filter(
(cert) =>
cert.name?.includes("tmp-e2e") && // Certs are more than an hour old
Date.now() - new Date(cert.uploaded_on).valueOf() > 1000 * 60 * 60
);
};
export const deleteCertificate = async (id: string) => {
return await apiFetch(`/mtls_certificates/${id}`, "DELETE");
};
export const listTmpAgentMemoryNamespaces = async () => {
// The Agent Memory API uses cursor-based pagination, so we follow cursors manually
// rather than using apiFetchList (which uses page-number pagination).
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}`;
const results: AgentMemoryNamespace[] = [];
let cursor: string | undefined;
while (true) {
const queryString = cursor
? "?" + new URLSearchParams({ cursor }).toString()
: "";
const url = `${baseUrl}/agent-memory/namespaces${queryString}`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
},
});
if (!response.ok) {
console.error(
"Failed to list Agent Memory namespaces",
response.status,
await response.text()
);
return [];
}
const json = (await response.json()) as {
result: AgentMemoryNamespace[];
result_info?: { cursor?: string };
};
results.push(...json.result);
const nextCursor = json.result_info?.cursor;
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return results.filter(
(ns) =>
ns.name.startsWith("tmp-e2e-") &&
// Namespaces are more than an hour old
Date.now() - new Date(ns.created_at).valueOf() > 1000 * 60 * 60
);
};
export const deleteAgentMemoryNamespace = async (name: string) => {
const result = await apiFetch(`/agent-memory/namespaces/${name}`, "DELETE");
// If successful the result is `null`, if the namespace is not found the result is `false`
return result !== false;
};
// Note: the container images functions below don't directly use the REST API since
// they interact with the cloudflare images registry which has it's own
// non-trivial auth mechanism, so instead of duplicating a bunch of logic
// here we instead directly call wrangler
//
// TODO: Consider if it would make sense for all the functions here to use wrangler
// directly, what is the advantage of hitting the REST API directly?
export const listE2eContainerImages = () => {
const output = runWranglerCommand("wrangler containers images list", {
// This operation can take literally minutes to run...
timeout: 3 * 60_000,
}).stdout;
return output
.split("\n")
.map((line) => {
const match = line.match(
/^(?<imageName>tmp-e2e-worker[a-z0-9-]+)\s+tmp-e2e$/
);
if (!match?.groups) {
return null;
}
return { name: match.groups.imageName, tag: "tmp-e2e" };
})
.filter(Boolean) as { name: string; tag: string }[];
};
export const deleteContainerImage = (image: { name: string; tag: string }) => {
const run = runWranglerCommand(
`wrangler containers images delete ${image.name}:${image.tag}`
);
return run.status === 0;
};
function runWranglerCommand(
wranglerCommand: string,
{ timeout = 10_000 } = {}
) {
// Enforce a `wrangler` prefix to make commands clearer to read
assert(
wranglerCommand.startsWith("wrangler "),
"Commands must start with `wrangler` (e.g. `wrangler dev`) but got " +
wranglerCommand
);
const { status, stdout, stderr, output } = spawnSync(
"pnpm",
[wranglerCommand],
{
shell: true,
stdio: "pipe",
encoding: "utf8",
timeout,
}
);
return {
status,
stdout,
stderr,
output: output.filter((line) => line !== null).join("\n"),
};
}
+216
View File
@@ -0,0 +1,216 @@
import {
deleteAgentMemoryNamespace,
deleteCertificate,
deleteContainerApplication,
deleteContainerImage,
deleteDatabase,
deleteHyperdriveConfig,
deleteKVNamespace,
deleteProject,
deleteR2Bucket,
deleteWorker,
listCertificates,
listE2eContainerImages,
listHyperdriveConfigs,
listTmpAgentMemoryNamespaces,
listTmpDatabases,
listTmpE2EContainerApplications,
listTmpE2EProjects,
listTmpE2EWorkers,
listTmpKVNamespaces,
listTmpR2Buckets,
} from "./common";
if (!process.env.CLOUDFLARE_API_TOKEN) {
console.error("CLOUDFLARE_API_TOKEN must be set");
process.exit(1);
}
if (!process.env.CLOUDFLARE_ACCOUNT_ID) {
console.error("CLOUDFLARE_ACCOUNT_ID must be set");
process.exit(1);
}
run().catch((e) => {
if ("code" in e) {
process.exit(e.code);
}
});
async function run() {
// KV namespaces don't have a creation timestamp, but deletion will fail if there is a worker bound to it
// so delete these first to avoid interrupting running e2e jobs (unless you are very very unlucky)
await deleteKVNamespaces();
await deleteR2Buckets();
await deleteProjects();
await deleteWorkers();
await deleteD1Databases();
await deleteHyperdriveConfigs();
await deleteMtlsCertificates();
await deleteContainerApplications();
await deleteAgentMemoryNamespaces();
deleteContainerImages();
}
async function deleteKVNamespaces() {
const kvNamespacesToDelete = await listTmpKVNamespaces();
for (const kvNamespace of kvNamespacesToDelete) {
console.log("Deleting KV namespace: " + kvNamespace.title);
if (await deleteKVNamespace(kvNamespace.id)) {
console.log(`Successfully deleted KV namespace ${kvNamespace.id}`);
} else {
console.log(`Failed to delete KV namespace ${kvNamespace.id}`);
}
}
if (kvNamespacesToDelete.length === 0) {
console.log(`No KV namespaces to delete.`);
}
}
async function deleteProjects() {
const projectsToDelete = await listTmpE2EProjects();
for (const project of projectsToDelete) {
console.log("Deleting Pages project: " + project.name);
if (await deleteProject(project.name)) {
console.log(`Successfully deleted project ${project.name}`);
} else {
console.log(`Failed to delete project ${project.name}`);
}
}
if (projectsToDelete.length === 0) {
console.log(`No projects to delete.`);
}
}
function deleteContainerImages() {
const containerImagesToDelete = listE2eContainerImages();
for (const image of containerImagesToDelete) {
console.log("Deleting Container image: " + image.name + ":" + image.tag);
if (deleteContainerImage(image)) {
console.log(`Successfully deleted project ${image.name}:${image.tag}`);
} else {
console.log(`Failed to delete project ${image.name}:${image.tag}`);
}
}
if (containerImagesToDelete.length === 0) {
console.log(`No container image to delete.`);
}
}
async function deleteWorkers() {
const workersToDelete = await listTmpE2EWorkers();
for (const worker of workersToDelete) {
console.log("Deleting worker: " + worker.script_name);
if (await deleteWorker(worker.script_name)) {
console.log(`Successfully deleted Worker ${worker.script_name}`);
} else {
console.log(`Failed to delete Worker ${worker.script_name}`);
}
}
if (workersToDelete.length === 0) {
console.log(`No workers to delete.`);
}
}
async function deleteD1Databases() {
const d1DatabasesToDelete = await listTmpDatabases();
for (const db of d1DatabasesToDelete) {
console.log("Deleting D1 database: " + db.name);
if (await deleteDatabase(db.uuid)) {
console.log(`Successfully deleted D1 database ${db.uuid}`);
} else {
console.log(`Failed to delete D1 database ${db.uuid}`);
}
}
if (d1DatabasesToDelete.length === 0) {
console.log(`No D1 databases to delete.`);
}
}
async function deleteHyperdriveConfigs() {
const hyperdriveConfigsToDelete = await listHyperdriveConfigs();
for (const config of hyperdriveConfigsToDelete) {
console.log("Deleting Hyperdrive configs: " + config.id);
if (await deleteHyperdriveConfig(config.id)) {
console.log(`Successfully deleted Hyperdrive config ${config.id}`);
} else {
console.log(`Failed to delete Hyperdrive config ${config.id}`);
}
}
if (hyperdriveConfigsToDelete.length === 0) {
console.log(`No Hyperdrive configs to delete.`);
}
}
async function deleteMtlsCertificates() {
const mtlsCertificates = await listCertificates();
for (const certificate of mtlsCertificates) {
console.log("Deleting mTLS certificate: " + certificate.id);
if (await deleteCertificate(certificate.id)) {
console.log(`Successfully deleted mTLS certificate ${certificate.id}`);
} else {
console.log(`Failed to delete mTLS certificate ${certificate.id}`);
}
}
if (mtlsCertificates.length === 0) {
console.log(`No mTLS certificates to delete.`);
}
}
async function deleteAgentMemoryNamespaces() {
const namespacesToDelete = await listTmpAgentMemoryNamespaces();
for (const ns of namespacesToDelete) {
console.log("Deleting Agent Memory namespace: " + ns.name);
if (await deleteAgentMemoryNamespace(ns.name)) {
console.log(`Successfully deleted Agent Memory namespace ${ns.name}`);
} else {
console.log(`Failed to delete Agent Memory namespace ${ns.name}`);
}
}
if (namespacesToDelete.length === 0) {
console.log(`No Agent Memory namespaces to delete.`);
}
}
async function deleteContainerApplications() {
const containers = await listTmpE2EContainerApplications();
for (const container of containers) {
await deleteContainerApplication(container);
console.log(`Deleted ${container.name} (${container.id})`);
}
}
async function deleteR2Buckets() {
const bucketsToDelete = await listTmpR2Buckets();
for (const bucket of bucketsToDelete) {
console.log("Deleting R2 bucket: " + bucket.name);
if (await deleteR2Bucket(bucket.name)) {
console.log(`Successfully deleted R2 bucket ${bucket.name}`);
} else {
console.log(`Failed to delete R2 bucket ${bucket.name}`);
}
}
if (bucketsToDelete.length === 0) {
console.log(`No R2 buckets to delete.`);
}
}
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert";
import { execSync } from "node:child_process";
import { statSync } from "node:fs";
import path from "node:path";
import { globSync } from "tinyglobby";
import type { ExecSyncOptionsWithBufferEncoding } from "node:child_process";
// Turbo only supports caching on the individual task level, but for Wrangler's
// e2e tests we want to support caching on a more granular basis - at the file level.
//
// As such, we run the `test:e2e` turbo task multiple times — once per e2e test file so that each file's tests can be cached individually.
// We use the `WRANGLER_E2E_TEST_FILE` environment variable to pass the specific test file to the e2e test runner so that it reuses the cached build tasks.
// If you use a command line argument to do this turbo will create a different cache key for the build tasks.
//
// The intended flow here is that CI will run this file, which will trigger turbo to run
// an individual task for each Wrangler e2e test file, using `execSync`.
//
// Any params after a `--` will be passed to the Vitest runner, so you can use this to configure the test run.
// For example to update the snapshots for all Wrangler e2e tests, you can run:
//
// ```bash
// pnpm test:e2e:wrangler -- -u
// ```
//
// ## Sharding
//
// Set `E2E_SHARD` and `E2E_SHARD_COUNT` to split the test files across multiple
// CI jobs. Each shard gets a subset of files, balanced by estimated test duration
// using greedy bin-packing. For example:
//
// E2E_SHARD=1 E2E_SHARD_COUNT=4 pnpm test:e2e:wrangler
//
// runs the first shard's files. When unset, all files run (no sharding).
const RETRIES = 4;
const extraParamsIndex = process.argv.indexOf("--");
const extraParams =
extraParamsIndex === -1 ? [] : process.argv.slice(extraParamsIndex);
const command =
`pnpm test:e2e --log-order=stream --output-logs=new-only --summarize --filter wrangler ` +
extraParams.join(" ");
const wranglerPath = path.join(__dirname, "../../packages/wrangler");
assert(statSync(wranglerPath).isDirectory());
let tests = globSync("e2e/**/*.test.ts", {
cwd: wranglerPath,
});
// Apply sharding if E2E_SHARD and E2E_SHARD_COUNT are set.
// Uses greedy bin-packing by estimated duration so shards are roughly balanced.
// Files not in the duration map get a default estimate.
const shardIndex = process.env.E2E_SHARD
? parseInt(process.env.E2E_SHARD, 10)
: undefined;
const shardCount = process.env.E2E_SHARD_COUNT
? parseInt(process.env.E2E_SHARD_COUNT, 10)
: undefined;
if (shardIndex !== undefined && shardCount !== undefined) {
assert(shardCount >= 1, `E2E_SHARD_COUNT must be >= 1, got ${shardCount}`);
assert(
shardIndex >= 1 && shardIndex <= shardCount,
`E2E_SHARD must be between 1 and E2E_SHARD_COUNT (${shardCount}), got ${shardIndex}`
);
// Estimated durations (seconds) from CI measurements (with remote tests enabled).
// Used for load-balancing across shards. Doesn't need to be exact — just roughly
// right. Files not listed here get a default of 30s. Update periodically from CI
// timing data. Last calibrated: 2026-03-17 from run 23205759327.
const estimatedDurations: Record<string, number> = {
"e2e/dev.test.ts": 181,
"e2e/versions.test.ts": 153,
"e2e/remote-binding/miniflare-remote-resources.test.ts": 105,
"e2e/provision.test.ts": 95,
"e2e/unenv-preset/preset.test.ts": 85,
"e2e/containers.dev.test.ts": 77,
"e2e/deploy.test.ts": 72,
"e2e/start-worker-auth-opts.test.ts": 61,
"e2e/assets-multiworker.test.ts": 53,
"e2e/remote-binding/dev-remote-bindings.test.ts": 53,
"e2e/remote-binding/remote-bindings-api.test.ts": 51,
"e2e/deployments.test.ts": 48,
"e2e/remote-binding/start-worker-remote-bindings.test.ts": 38,
"e2e/types.test.ts": 36,
"e2e/startWorker.test.ts": 31,
"e2e/get-platform-proxy.test.ts": 30,
"e2e/pages-dev.test.ts": 27,
"e2e/dev-registry.test.ts": 27,
"e2e/c3-integration.test.ts": 25,
"e2e/pages-deploy.test.ts": 23,
"e2e/multiworker-dev.test.ts": 21,
"e2e/cert.test.ts": 21,
"e2e/secrets-store.test.ts": 18,
"e2e/r2.test.ts": 15,
"e2e/dev-env.test.ts": 8,
"e2e/autoconfig/setup.test.ts": 2,
};
const DEFAULT_DURATION = 30;
const getDuration = (file: string) =>
estimatedDurations[file] ?? DEFAULT_DURATION;
// Greedy bin-packing: sort by duration desc, assign each file to the
// lightest shard. Produces near-optimal balance.
const sortedByDuration = [...tests].sort(
(a, b) => getDuration(b) - getDuration(a)
);
const shards: string[][] = Array.from({ length: shardCount }, () => []);
const shardTotals = new Float64Array(shardCount);
for (const file of sortedByDuration) {
// Find the shard with the lowest total duration
let minIdx = 0;
for (let i = 1; i < shardCount; i++) {
if (shardTotals[i] < shardTotals[minIdx]) {
minIdx = i;
}
}
shards[minIdx].push(file);
shardTotals[minIdx] += getDuration(file);
}
const totalFiles = tests.length;
tests = shards[shardIndex - 1];
console.log(
`Shard ${shardIndex}/${shardCount}: running ${tests.length}/${totalFiles} test files (~${Math.round(shardTotals[shardIndex - 1])}s estimated)` +
tests.map((file) => `\n - ${file}`).join("")
);
// Log all shard assignments for debugging
for (let i = 0; i < shardCount; i++) {
console.log(
` Shard ${i + 1}: ${shards[i].length} files, ~${Math.round(shardTotals[i])}s`
);
}
}
const failedTest = new Set<string>();
for (let i = 0; i < RETRIES; i++) {
if (i > 0) {
console.log(
`Retrying ${tests.length} failed tests...` +
tests.map((file) => `\n - ${file}`).join("")
);
}
failedTest.clear();
for (const testFile of tests) {
const options: ExecSyncOptionsWithBufferEncoding = {
stdio: "inherit",
env: { ...process.env, WRANGLER_E2E_TEST_FILE: testFile },
};
console.log(`::group::Testing: ${testFile}`);
try {
execSync(command, options);
} catch {
failedTest.add(testFile);
}
console.log("::endgroup::");
}
if (failedTest.size === 0) {
process.exit(0);
}
tests = [...failedTest];
}
if (failedTest.size > 0) {
console.error(
"At least one task failed (even on retry):" +
[...failedTest].map((file) => `\n - ${file}`).join("")
);
process.exit(1);
}
@@ -0,0 +1,246 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import dedent from "ts-dedent";
import { afterEach, beforeEach, describe, it } from "vitest";
import { validateActionPinning } from "../validate-action-pinning";
describe("validateActionPinning()", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "action-pinning-")));
mkdirSync(join(tmpDir, ".github", "workflows"), { recursive: true });
});
afterEach(() => {
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- test cleanup
rmSync(tmpDir, { recursive: true, force: true });
});
function writeWorkflow(filename: string, content: string) {
writeFileSync(
join(tmpDir, ".github", "workflows", filename),
content,
"utf-8"
);
}
function writeCompositeAction(actionDir: string, content: string) {
const dir = join(tmpDir, ".github", "actions", actionDir);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "action.yml"), content, "utf-8");
}
it("should pass for pinned third-party actions", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
`
);
expect(validateActionPinning(tmpDir)).toEqual([]);
});
it("should pass for pinned actions with a version comment", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
`
);
expect(validateActionPinning(tmpDir)).toEqual([]);
});
it("should fail for third-party actions pinned to a tag", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: dorny/paths-filter@v3
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("dorny/paths-filter@v3");
expect(errors[0]).toContain("not pinned to a commit SHA");
});
it("should fail for third-party actions pinned to a semver tag", ({
expect,
}) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: Ana06/get-changed-files@v2.3.0
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("Ana06/get-changed-files@v2.3.0");
});
it("should fail for third-party actions pinned to a branch", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: changesets/action@main
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("changesets/action@main");
});
it("should skip first-party actions/* regardless of pinning", ({
expect,
}) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/upload-artifact@v4
`
);
expect(validateActionPinning(tmpDir)).toEqual([]);
});
it("should skip local actions", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: ./.github/actions/install-dependencies
`
);
expect(validateActionPinning(tmpDir)).toEqual([]);
});
it("should report multiple errors across multiple files", ({ expect }) => {
writeWorkflow(
"a.yml",
dedent`
jobs:
build:
steps:
- uses: foo/bar@v1
`
);
writeWorkflow(
"b.yml",
dedent`
jobs:
build:
steps:
- uses: baz/qux@v2
- uses: quux/corge@latest
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(3);
const joined = errors.join("\n");
expect(joined).toContain("foo/bar@v1");
expect(joined).toContain("baz/qux@v2");
expect(joined).toContain("quux/corge@latest");
});
it("should validate composite actions in .github/actions/", ({ expect }) => {
writeCompositeAction(
"my-action",
dedent`
runs:
using: composite
steps:
- uses: unpinned/action@v1
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("unpinned/action@v1");
expect(errors[0]).toContain(".github/actions/my-action/action.yml");
});
it("should include the correct line number in errors", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
name: Test
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: unpinned/action@v3
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(":8");
});
it("should handle uses without a hyphen prefix", ({ expect }) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- name: Do something
uses: unpinned/action@v1
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("unpinned/action@v1");
});
it("should fail for third-party actions with no version reference at all", ({
expect,
}) => {
writeWorkflow(
"test.yml",
dedent`
jobs:
build:
steps:
- uses: some-org/some-action
`
);
const errors = validateActionPinning(tmpDir);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("some-org/some-action");
expect(errors[0]).toContain("has no version reference at all");
});
it("should pass when there are no workflow files", ({ expect }) => {
// tmpDir has the .github/workflows/ directory but no files
expect(validateActionPinning(tmpDir)).toEqual([]);
});
});
@@ -0,0 +1,106 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { globSync } from "tinyglobby";
/**
* Matches `uses:` directives in GitHub Actions workflow YAML files.
* Captures the action reference (everything after `uses:`), trimmed.
*
* Examples that match:
* - uses: actions/checkout@v4
* uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
*/
const USES_RE = /^\s*-?\s*uses:\s*(.+)$/;
/**
* A valid SHA pin is exactly 40 lowercase hex characters.
*/
const SHA_RE = /^[0-9a-f]{40}$/;
/**
* First-party GitHub action prefixes that are trusted and do not
* need to be pinned to a commit SHA.
*/
const TRUSTED_PREFIXES = ["actions/"];
/**
* Checks that all third-party GitHub Actions in workflow and composite
* action files are pinned to a full commit SHA rather than a mutable
* tag or branch reference.
*
* @param repoRoot - Absolute path to the repository root directory.
* @returns An array of human-readable error strings (empty = all good).
*/
export function validateActionPinning(repoRoot: string): string[] {
const patterns = [
".github/workflows/*.yml",
".github/workflows/*.yaml",
".github/actions/*/action.yml",
".github/actions/*/action.yaml",
];
const files = patterns.flatMap((pattern) =>
globSync(pattern, { cwd: repoRoot })
);
const errors: string[] = [];
for (const relPath of files) {
const absPath = resolve(repoRoot, relPath);
const content = readFileSync(absPath, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(USES_RE);
if (!match) {
continue;
}
const raw = match[1].trim();
// Local actions (e.g. ./.github/actions/foo) — skip
if (raw.startsWith("./") || raw.startsWith("../")) {
continue;
}
// Trusted first-party actions — skip
if (TRUSTED_PREFIXES.some((prefix) => raw.startsWith(prefix))) {
continue;
}
// At this point it's a third-party action.
// Expected format: owner/repo@<ref> (possibly with a trailing comment)
const atIndex = raw.indexOf("@");
if (atIndex === -1) {
errors.push(
`${relPath}:${i + 1}${raw.split(/\s/)[0]} has no version reference at all`
);
continue;
}
// The ref is everything after @ up to the first space (comments follow a space)
const afterAt = raw.slice(atIndex + 1).split(/\s/)[0];
if (!SHA_RE.test(afterAt)) {
errors.push(
`${relPath}:${i + 1}${raw.split(/\s/)[0]} is not pinned to a commit SHA`
);
}
}
}
return errors;
}
if (require.main === module) {
console.log("::group::Checking GitHub Action pinning");
const repoRoot = resolve(__dirname, "../..");
const errors = validateActionPinning(repoRoot);
if (errors.length > 0) {
console.error(
"::error::Action pinning checks:" + errors.map((e) => `\n- ${e}`).join("")
);
}
console.log("::endgroup::");
process.exit(errors.length > 0 ? 1 : 0);
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@cloudflare/tools",
"private": true,
"description": "Tooling for this monorepo CI",
"scripts": {
"check:type": "tsc",
"test:ci": "vitest run",
"test:file": "node -r esbuild-register test/run-test-file.ts"
},
"devDependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
"@cloudflare/workers-tsconfig": "workspace:*",
"@octokit/webhooks-types": "^7.6.1",
"@types/semver": "^7.5.1",
"empathic": "^2.0.0",
"semver": "^7.7.1",
"tinyglobby": "catalog:default",
"ts-dedent": "^2.2.0",
"undici": "catalog:default",
"wrangler": "workspace:*"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import * as find from "empathic/find";
const currentFile = process.argv[2];
const currentDirectory = path.dirname(currentFile);
const packageJsonPath = find.file("package.json", { cwd: currentDirectory });
if (!packageJsonPath) {
console.error("No package.json found.");
process.exit(1);
}
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonContent);
const packageName = packageJson.name;
const command = `pnpm`;
const args = ["test", "-F", packageName, "--", currentFile];
const testProcess = spawn(command, args, { stdio: "inherit" });
testProcess.on("close", (code) => {
process.exit(code ?? 1);
});
+13
View File
@@ -0,0 +1,13 @@
{
"root": true,
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"include": ["**/*.ts", "**/*.js", "**/*.mts"],
"exclude": ["node_modules"],
"compilerOptions": {
"module": "preserve",
"allowJs": true,
"outDir": "dist",
"types": ["node"],
"resolveJsonModule": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "http://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"test:ci": {
"inputs": ["../packages/*/package.json"],
"dependsOn": ["build"]
},
"build": {
"env": ["PUBLISHED_PACKAGES"]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineProject, mergeConfig } from "vitest/config";
import configShared from "../vitest.shared";
export default mergeConfig(
configShared,
defineProject({
test: {
include: ["**/__tests__/*.test.ts"],
retry: 0,
},
})
);