chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars -- this script reads CI environment variables */
|
||||
if (require.main === module) {
|
||||
const status = [];
|
||||
|
||||
if (process.env.PUBLISH_STATUS === "failure") {
|
||||
if (process.env.HAS_CHANGESETS === "true") {
|
||||
console.log(
|
||||
"Version PR creation failed. Please retry the job if needed."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
status.push({
|
||||
label: "NPM publish",
|
||||
details: "Packages failed to publish",
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.DEPLOYMENT_STATUS) {
|
||||
try {
|
||||
const deploymentStatus = JSON.parse(
|
||||
process.env.DEPLOYMENT_STATUS as string
|
||||
);
|
||||
|
||||
for (const [pkg, err] of Object.entries(deploymentStatus)) {
|
||||
status.push({
|
||||
label: pkg,
|
||||
details: err,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
status.push({
|
||||
label: "Deployment status",
|
||||
details: `Failed to parse deployment status: ${e}. Received: "${process.env.DEPLOYMENT_STATUS}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (status.length > 0) {
|
||||
void fetch(
|
||||
"https://devprod-status-bot.devprod.workers.dev/release-failure",
|
||||
{
|
||||
body: JSON.stringify({ status, url: process.env.RUN_URL }),
|
||||
headers: {
|
||||
"X-Auth-Header": process.env.TOKEN as string,
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { compare } from "semver";
|
||||
|
||||
/**
|
||||
* For Trusted Publishing to work, we need npm version 11.5.1 or higher.
|
||||
*/
|
||||
function checkNpmVersion() {
|
||||
const npmVersionBuffer = execSync("npm --version");
|
||||
const npmVersion = npmVersionBuffer.toString().trim();
|
||||
if (compare(npmVersion, "11.5.1") === -1) {
|
||||
console.error(
|
||||
`Error: npm version 11.5.1 or higher is required for Trusted Publishing to work, found version ${npmVersion}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
checkNpmVersion();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[2] === "check") {
|
||||
findDeployablePackageNames();
|
||||
} else {
|
||||
deployNonNpmPackages(getUpdatedPackages(), findDeployablePackageNames());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy all packages that had updates but are not deployed to npm automatically by the changesets tooling.
|
||||
*
|
||||
* @param updatedPackages An array of all the packages that were updated by changesets.
|
||||
* @param deployablePackageNames A set of the names of packages that can be deployed by this script.
|
||||
*/
|
||||
export function deployNonNpmPackages(
|
||||
updatedPackages: UpdatedPackage[],
|
||||
deployablePackageNames: Set<string>
|
||||
) {
|
||||
let deployedPackageCount = 0;
|
||||
console.log("Checking for non-npm packages to deploy...");
|
||||
const deploymentErrors = new Map<string, string>();
|
||||
for (const pkg of updatedPackages) {
|
||||
if (deployablePackageNames.has(pkg.name)) {
|
||||
console.log(`Package "${pkg.name}@${pkg.version}": deploying...`);
|
||||
deployPackage(pkg.name, deploymentErrors);
|
||||
deployedPackageCount++;
|
||||
} else {
|
||||
console.log(
|
||||
`Package "${pkg.name}@${pkg.version}": already deployed via npm.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (deployedPackageCount === 0) {
|
||||
console.log("No non-npm packages to deploy.");
|
||||
} else {
|
||||
console.log(`Deployed ${deployedPackageCount} non-npm packages.`);
|
||||
}
|
||||
writeFileSync(
|
||||
"deployment-status.json",
|
||||
JSON.stringify(Object.fromEntries(deploymentErrors.entries()))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all the packages that got bumped by changesets from the PUBLISHED_PACKAGES environment variable.
|
||||
*/
|
||||
export function getUpdatedPackages(): UpdatedPackage[] {
|
||||
const packages = JSON.parse(process.env.PUBLISHED_PACKAGES ?? "[]");
|
||||
|
||||
assert(
|
||||
Array.isArray(packages),
|
||||
`Expected PUBLISHED_PACKAGES to be an array but got ${typeof packages}.`
|
||||
);
|
||||
packages.forEach((p, i) => {
|
||||
assert(
|
||||
typeof p === "object" && p !== null,
|
||||
`Expected item ${i} in array to be an array but got ${typeof p}.`
|
||||
);
|
||||
assert(
|
||||
typeof p.name === "string",
|
||||
`Expected item ${i} to have a "name" property of type string but got ${p.name}.`
|
||||
);
|
||||
assert(
|
||||
typeof p.version === "string",
|
||||
`Expected item ${i} to have a "version" property of type string but got ${p.version}.`
|
||||
);
|
||||
});
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for all the packages (under the top-level "packages" directory of the monorepo)
|
||||
* that can be deployed using this script.
|
||||
*
|
||||
* This is determined by the package containing a package.json "workers-sdk": { "deploy": true }`
|
||||
*/
|
||||
export function findDeployablePackageNames(): Set<string> {
|
||||
const packagesDirectory = resolve(__dirname, "../../packages");
|
||||
const allPackageDirectories = readdirSync(packagesDirectory).map((p) =>
|
||||
resolve(packagesDirectory, p)
|
||||
);
|
||||
const allPackages: PackageJSON[] = [];
|
||||
for (const dir of allPackageDirectories) {
|
||||
try {
|
||||
allPackages.push(
|
||||
JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"))
|
||||
);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
const deployablePackages = new Set<string>();
|
||||
for (const pkg of allPackages) {
|
||||
if (pkg["workers-sdk"]?.deploy) {
|
||||
assert(
|
||||
pkg.scripts?.deploy !== undefined,
|
||||
`Expected package "${pkg.name}" to have a deploy script`
|
||||
);
|
||||
deployablePackages.add(pkg.name);
|
||||
}
|
||||
}
|
||||
return deployablePackages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to run `pnpm deploy` on the given package in the monorepo.
|
||||
*
|
||||
* If this deployment fails, log an error and continue.
|
||||
*
|
||||
* @param pkgName the package to deploy
|
||||
*/
|
||||
export function deployPackage(
|
||||
pkgName: string,
|
||||
deploymentErrors: Map<string, string>
|
||||
) {
|
||||
try {
|
||||
spawnSync(`pnpm`, [`-F`, pkgName, `run`, `deploy`], {
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`::error::Failed to deploy "${pkgName}".`);
|
||||
console.error(
|
||||
"Work out why this happened and then potentially run a manual deployment."
|
||||
);
|
||||
console.error(e);
|
||||
deploymentErrors.set(pkgName, String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdatedPackage = { name: string; version: string };
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
scripts?: Record<string, unknown>;
|
||||
"workers-sdk"?: {
|
||||
deploy?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Validates that workspace packages use `catalog:` references for any dependency
|
||||
* that exists in the pnpm catalog. Exceptions: `workspace:` refs, `npm:` aliases,
|
||||
* and packages under `templates/` (scaffolded for end users).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
|
||||
// Deps that are deliberately pinned outside the catalog (e.g. workerd is
|
||||
// bumped in coordinated PRs with its own automation).
|
||||
const IGNORED_DEPS = new Set(["workerd"]);
|
||||
|
||||
/**
|
||||
* Parses the `catalog:` block of a pnpm-workspace.yaml into a map of
|
||||
* dependency name -> version specifier.
|
||||
*/
|
||||
export function parseCatalog(workspaceYaml: string): Map<string, string> {
|
||||
const catalog = new Map<string, string>();
|
||||
let inCatalog = false;
|
||||
|
||||
for (const line of workspaceYaml.split("\n")) {
|
||||
if (line.startsWith("catalog:")) {
|
||||
inCatalog = true;
|
||||
continue;
|
||||
}
|
||||
if (inCatalog && /^\S/.test(line)) {
|
||||
break;
|
||||
}
|
||||
if (!inCatalog) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(
|
||||
/^["']?(@?[^"':]+)["']?\s*:\s*["']?([^"'#\s]+)["']?/
|
||||
);
|
||||
if (match) {
|
||||
catalog.set(match[1].trim(), match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the pnpm catalog as a map of dependency name -> version specifier.
|
||||
*/
|
||||
export function loadCatalog(): Map<string, string> {
|
||||
const content = readFileSync(resolve(ROOT, "pnpm-workspace.yaml"), "utf-8");
|
||||
return parseCatalog(content);
|
||||
}
|
||||
|
||||
function loadCatalogDeps(): Set<string> {
|
||||
return new Set(loadCatalog().keys());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("::group::Checking catalog usage");
|
||||
|
||||
const catalogDeps = loadCatalogDeps();
|
||||
const errors: string[] = [];
|
||||
|
||||
const packageJsonPaths = await glob(
|
||||
[
|
||||
"package.json",
|
||||
"packages/*/package.json",
|
||||
"packages/vite-plugin-cloudflare/playground/*/package.json",
|
||||
"packages/vite-plugin-cloudflare/playground/package.json",
|
||||
"fixtures/*/package.json",
|
||||
"tools/package.json",
|
||||
],
|
||||
{ cwd: ROOT, absolute: true }
|
||||
);
|
||||
|
||||
for (const pkgPath of packageJsonPaths) {
|
||||
if (pkgPath.includes("/templates/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const rel = pkgPath.replace(ROOT + "/", "");
|
||||
|
||||
for (const section of ["dependencies", "devDependencies"] as const) {
|
||||
for (const [name, version] of Object.entries(
|
||||
(pkg[section] ?? {}) as Record<string, string>
|
||||
)) {
|
||||
if (
|
||||
!catalogDeps.has(name) ||
|
||||
IGNORED_DEPS.has(name) ||
|
||||
version.startsWith("catalog:") ||
|
||||
version.startsWith("workspace:") ||
|
||||
version.startsWith("npm:")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(
|
||||
`${rel}: "${name}" uses "${version}" in ${section} but should use "catalog:default"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Catalog usage violations:" +
|
||||
errors.map((e) => `\n- ${e}`).join("")
|
||||
);
|
||||
} else {
|
||||
console.log("All catalog dependencies are correctly referenced.");
|
||||
}
|
||||
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import parseChangeset from "@changesets/parse";
|
||||
|
||||
if (require.main === module) {
|
||||
const packages = findPackages();
|
||||
const changesets = readChangesets(resolve(__dirname, "../../.changeset"));
|
||||
const errors = validateChangesets(packages, changesets);
|
||||
if (errors.length > 0) {
|
||||
console.error("Validation errors in changesets:");
|
||||
for (const error of errors) {
|
||||
console.error("- ", error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChangesets(
|
||||
packages: Map<string, PackageJSON>,
|
||||
changesets: ChangesetFile[]
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
changesets.map(({ file, contents }) => {
|
||||
try {
|
||||
const changeset = parseChangeset(contents);
|
||||
for (const release of changeset.releases) {
|
||||
const targetPackage = packages.get(release.name);
|
||||
if (!targetPackage) {
|
||||
errors.push(
|
||||
`Unknown package name "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (release.type === "major" && targetPackage?.private !== true) {
|
||||
errors.push(
|
||||
`Major version bumps are not allowed for package "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
if (!["major", "minor", "patch", "none"].includes(release.type)) {
|
||||
errors.push(
|
||||
`Invalid type "${release.type}" for package "${release.name}" in changeset at "${file}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
errors.push(e.toString() + `at file "${file}"`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function readChangesets(changesetDir: string): ChangesetFile[] {
|
||||
return readdirSync(changesetDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.startsWith(".") && file.endsWith(".md") && file !== "README.md"
|
||||
)
|
||||
.map((file) => ({
|
||||
file,
|
||||
contents: readFileSync(resolve(changesetDir, file), "utf-8"),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for all the packages (under the top-level "packages" directory of the monorepo) that can have changesets.
|
||||
*
|
||||
* This is determined by the package containing a package.json that is public or
|
||||
* is marked as `"private": true` and has a `deploy` script.
|
||||
*/
|
||||
export function findPackages(): Map<string, PackageJSON> {
|
||||
const packagesDirectory = resolve(__dirname, "../../packages");
|
||||
const allPackageDirectories = readdirSync(packagesDirectory).map((p) =>
|
||||
resolve(packagesDirectory, p)
|
||||
);
|
||||
const allPackages: PackageJSON[] = [];
|
||||
for (const dir of allPackageDirectories) {
|
||||
try {
|
||||
allPackages.push(
|
||||
JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"))
|
||||
);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
const deployablePackages = new Map<string, PackageJSON>();
|
||||
for (const pkg of allPackages) {
|
||||
if (!pkg.private || pkg.scripts?.deploy !== undefined) {
|
||||
deployablePackages.set(pkg.name, pkg);
|
||||
}
|
||||
}
|
||||
return deployablePackages;
|
||||
}
|
||||
|
||||
export type ChangesetFile = {
|
||||
file: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
scripts?: Record<string, unknown>;
|
||||
"workers-sdk"?: {
|
||||
deploy?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking fixtures");
|
||||
const errors = checkFixtures();
|
||||
if (errors.length > 0) {
|
||||
console.error("::error::Fixture checks:" + errors.map((e) => `\n- ${e}`));
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
function getFixtures() {
|
||||
const fixturesDirectory = resolve(__dirname, "../../fixtures");
|
||||
const allFixtureDirectories = readdirSync(fixturesDirectory);
|
||||
return allFixtureDirectories
|
||||
.map((dir) => {
|
||||
return {
|
||||
dir,
|
||||
packageJson: resolve(fixturesDirectory, dir, "package.json"),
|
||||
};
|
||||
})
|
||||
.filter(({ packageJson }) => {
|
||||
return existsSync(packageJson);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that we don't accidentally create git tags and releases for fixtures
|
||||
* by someone inadvertently adding a version to their package.json or making them non-private.
|
||||
*
|
||||
* Check the package.json for each fixture and ensure that they are all private and have no `version` property.
|
||||
*/
|
||||
export function checkFixtures(): string[] {
|
||||
const allFixtures = getFixtures();
|
||||
const errors: string[] = [];
|
||||
for (const { dir, packageJson } of allFixtures) {
|
||||
try {
|
||||
console.log(`- ${dir}`);
|
||||
const fixturePackage = JSON.parse(
|
||||
readFileSync(packageJson, "utf-8")
|
||||
) as PackageJSON;
|
||||
|
||||
// Check the package is not deployable.
|
||||
if (fixturePackage.private !== true) {
|
||||
errors.push(
|
||||
`Fixture "${dir}" is not private. Add \`"private": true\` to its package.json`
|
||||
);
|
||||
}
|
||||
|
||||
// Check the package does not have a version.
|
||||
if (fixturePackage.version) {
|
||||
errors.push(
|
||||
`Fixture "${dir}" has the disallowed "version" property. Please remove this.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check the package has a name starting with "@fixture/".
|
||||
if (!fixturePackage.name.startsWith("@fixture/")) {
|
||||
errors.push(
|
||||
`Fixture in directory "fixtures/${dir}" has a name that does not start with "@fixture/". Please rename it to start with "@fixture/".`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push(
|
||||
`Unable to load or parse fixture "${dir}" package. Please check it has a valid package.json.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type PackageJSON = {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
version?: string;
|
||||
};
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Validates that packages explicitly declare their external (non-bundled) dependencies.
|
||||
*
|
||||
* This prevents accidental dependency chain poisoning where a dependency of ours
|
||||
* has not pinned its versions and allows users to install unexpected upstream
|
||||
* transitive dependencies.
|
||||
*
|
||||
* Packages should bundle their dependencies into the distributable code via
|
||||
* devDependencies. Any dependency that MUST remain external (native binaries,
|
||||
* WASM, runtime-resolved code) should be:
|
||||
* 1. Listed in `dependencies` (or `peerDependencies`) in package.json
|
||||
* 2. Listed in `scripts/deps.ts` with EXTERNAL_DEPENDENCIES export
|
||||
* 3. Documented with a comment explaining WHY it can't be bundled
|
||||
*
|
||||
* In addition to validating the manifest, this script also scans the actual
|
||||
* built/published files (from the `files` field in package.json) for bare
|
||||
* import specifiers and checks they all resolve to a declared runtime
|
||||
* dependency or peer dependency. This catches cases where a bundler config's
|
||||
* `external` list drifts from `package.json` — for example, a devDependency
|
||||
* being incorrectly externalized would leave the published bundle with an
|
||||
* unresolved import.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
export interface PackageJSON {
|
||||
name: string;
|
||||
private?: boolean;
|
||||
main?: string;
|
||||
module?: string;
|
||||
exports?: unknown;
|
||||
bin?: string | Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
dir: string;
|
||||
packageJson: PackageJSON;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking package dependencies");
|
||||
checkPackageDependencies()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Package dependency checks:" + errors.map((e) => `\n- ${e}`)
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all non-private package.json files under packages/
|
||||
*/
|
||||
export async function getPublicPackages(): Promise<PackageInfo[]> {
|
||||
const packagesDir = resolve(__dirname, "../../packages");
|
||||
const packageJsonPaths = await glob("*/package.json", {
|
||||
cwd: packagesDir,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
return packageJsonPaths
|
||||
.map((packageJsonPath) => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8")
|
||||
) as PackageJSON;
|
||||
return {
|
||||
dir: dirname(packageJsonPath),
|
||||
packageJson,
|
||||
};
|
||||
})
|
||||
.filter(({ packageJson }) => !packageJson.private);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all dependency names from a package.json dependencies object
|
||||
*/
|
||||
export function getAllDependencies(
|
||||
dependencies: Record<string, string> | undefined
|
||||
): string[] {
|
||||
if (!dependencies) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the non-workspace dependencies from a package.json
|
||||
*/
|
||||
export function getNonWorkspaceDependencies(
|
||||
dependencies: Record<string, string> | undefined
|
||||
): string[] {
|
||||
if (!dependencies) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(dependencies)
|
||||
.filter(([, version]) => !version.startsWith("workspace:"))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load EXTERNAL_DEPENDENCIES from a package's scripts/deps.ts
|
||||
*/
|
||||
export function loadExternalDependencies(packageDir: string): string[] | null {
|
||||
const depsModule = loadDepsModule(packageDir);
|
||||
if (!depsModule || !Array.isArray(depsModule.EXTERNAL_DEPENDENCIES)) {
|
||||
return null;
|
||||
}
|
||||
return depsModule.EXTERNAL_DEPENDENCIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load IGNORED_DIST_IMPORTS from a package's scripts/deps.ts.
|
||||
*
|
||||
* `IGNORED_DIST_IMPORTS` is an allowlist of package names that the dist-scan
|
||||
* validator should not flag as missing dependencies. Use this for legitimate
|
||||
* but unfixable patterns such as:
|
||||
* - Optional imports inside try/catch blocks in bundled libraries
|
||||
* (e.g. `@netlify/build-info` probing for installed frameworks)
|
||||
* - Optional native binaries (e.g. `@aws-sdk/signature-v4-crt`)
|
||||
* - Code paths that are only reachable when consumers also install the
|
||||
* listed package themselves
|
||||
*/
|
||||
export function loadIgnoredDistImports(packageDir: string): string[] {
|
||||
const depsModule = loadDepsModule(packageDir);
|
||||
if (!depsModule || !Array.isArray(depsModule.IGNORED_DIST_IMPORTS)) {
|
||||
return [];
|
||||
}
|
||||
return depsModule.IGNORED_DIST_IMPORTS;
|
||||
}
|
||||
|
||||
function loadDepsModule(packageDir: string): {
|
||||
EXTERNAL_DEPENDENCIES?: string[];
|
||||
IGNORED_DIST_IMPORTS?: string[];
|
||||
} | null {
|
||||
const depsFilePath = resolve(packageDir, "scripts/deps.ts");
|
||||
if (!existsSync(depsFilePath)) {
|
||||
return null;
|
||||
}
|
||||
// Use require with esbuild-register (which is already loaded)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require needed for esbuild-register compatibility
|
||||
return require(depsFilePath) as {
|
||||
EXTERNAL_DEPENDENCIES?: string[];
|
||||
IGNORED_DIST_IMPORTS?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single package's dependencies against its allowlist.
|
||||
* Returns an array of error messages (empty if valid).
|
||||
*/
|
||||
export function validatePackageDependencies(
|
||||
packageName: string,
|
||||
relativePath: string,
|
||||
packageJson: PackageJSON,
|
||||
externalDeps: string[] | null
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Get non-workspace dependencies
|
||||
const nonWorkspaceDeps = getNonWorkspaceDependencies(
|
||||
packageJson.dependencies
|
||||
);
|
||||
|
||||
// Skip packages with no non-workspace dependencies
|
||||
if (nonWorkspaceDeps.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check if allowlist exists
|
||||
if (externalDeps === null) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has ${nonWorkspaceDeps.length} non-workspace dependencies ` +
|
||||
`but no scripts/deps.ts file with EXTERNAL_DEPENDENCIES export.\n` +
|
||||
` Create packages/${relativePath}/scripts/deps.ts with an EXTERNAL_DEPENDENCIES export ` +
|
||||
`listing all dependencies that cannot be bundled, with comments explaining why.\n` +
|
||||
` Dependencies: ${nonWorkspaceDeps.join(", ")}`
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check for dependencies not in the allowlist
|
||||
const undeclaredDeps = nonWorkspaceDeps.filter(
|
||||
(dep) => !externalDeps.includes(dep)
|
||||
);
|
||||
|
||||
for (const dep of undeclaredDeps) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has dependency "${dep}" that is not listed in ` +
|
||||
`EXTERNAL_DEPENDENCIES (scripts/deps.ts). Either:\n` +
|
||||
` 1. Bundle this dependency by moving it to devDependencies, or\n` +
|
||||
` 2. Add it to EXTERNAL_DEPENDENCIES with a comment explaining why it can't be bundled`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for stale entries in the allowlist (not in dependencies or peerDependencies)
|
||||
// Note: we check against ALL dependencies here (including workspace ones) because
|
||||
// EXTERNAL_DEPENDENCIES is also used by the bundler to mark dependencies as external
|
||||
const allDeclaredDeps = [
|
||||
...getAllDependencies(packageJson.dependencies),
|
||||
...getAllDependencies(packageJson.peerDependencies),
|
||||
];
|
||||
|
||||
const staleDeps = externalDeps.filter(
|
||||
(dep) => !allDeclaredDeps.includes(dep)
|
||||
);
|
||||
|
||||
for (const dep of staleDeps) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has "${dep}" in EXTERNAL_DEPENDENCIES but it's not in ` +
|
||||
`dependencies or peerDependencies. Remove it from scripts/deps.ts.`
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an import specifier, returns the package name part.
|
||||
*
|
||||
* Examples:
|
||||
* "lodash" -> "lodash"
|
||||
* "lodash/fp" -> "lodash"
|
||||
* "@cloudflare/workers-utils" -> "@cloudflare/workers-utils"
|
||||
* "@cloudflare/workers-utils/test-helpers" -> "@cloudflare/workers-utils"
|
||||
* "vitest/runtime" -> "vitest"
|
||||
*/
|
||||
export function getPackageNameFromSpecifier(spec: string): string {
|
||||
if (spec.startsWith("@")) {
|
||||
const parts = spec.split("/");
|
||||
return parts.slice(0, 2).join("/");
|
||||
}
|
||||
return spec.split("/")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specifier refers to an external bare package import
|
||||
* (i.e. not a built-in, virtual module, or relative path).
|
||||
*/
|
||||
export function isBareSpecifier(spec: string): boolean {
|
||||
if (!spec || spec.startsWith(".") || spec.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
// Node built-ins, both prefixed (node:fs) and unprefixed (fs).
|
||||
if (isBuiltin(spec)) {
|
||||
return false;
|
||||
}
|
||||
// Known non-package protocols used inside bundled user code / templates.
|
||||
// These appear inside string literals in wrangler's shipped code templates,
|
||||
// not as real imports of an npm package.
|
||||
if (/^[a-z][a-z0-9+\-.]*:/.test(spec) && !spec.startsWith("@")) {
|
||||
return false;
|
||||
}
|
||||
// Virtual / runtime-injected modules — anything in ALL_CAPS_WITH_UNDERSCORES
|
||||
// is conventionally a build-time virtual module (e.g. __VITEST_POOL_WORKERS_DEFINES).
|
||||
if (/^__[A-Z][A-Z0-9_]*$/.test(spec)) {
|
||||
return false;
|
||||
}
|
||||
// Specifier must look like a valid npm package name: lowercase letters/digits,
|
||||
// hyphens, dots, underscores; optionally scoped (@scope/name). npm package
|
||||
// names cannot contain uppercase letters per the npm naming rules.
|
||||
const packageNameRe =
|
||||
/^(?:@[a-z0-9._~-]+\/)?[a-z0-9][a-z0-9._~-]*(?:\/[a-z0-9._~-]+)*$/;
|
||||
if (!packageNameRe.test(spec)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all bare-specifier imports from a JS/CJS source file.
|
||||
*
|
||||
* Handles:
|
||||
* import x from "pkg"
|
||||
* import { x } from "pkg"
|
||||
* import * as x from "pkg"
|
||||
* import "pkg"
|
||||
* export ... from "pkg"
|
||||
* require("pkg")
|
||||
* await import("pkg") (only when specifier is a string literal)
|
||||
*
|
||||
* Returns top-level package names (e.g. "vitest" for "vitest/runtime").
|
||||
*
|
||||
* Uses esbuild's parser (via a `bundle: true` build with everything marked
|
||||
* external) so that specifiers found inside string literals, template
|
||||
* literals, comments, etc. are correctly ignored. `bundle: true` is required
|
||||
* to surface `require()` calls in the metafile — `bundle: false` only
|
||||
* reports ESM `import` statements. The `externalize-all` plugin short-
|
||||
* circuits resolution so esbuild never has to find the imports on disk.
|
||||
*/
|
||||
export async function extractBareImports(
|
||||
content: string
|
||||
): Promise<Set<string>> {
|
||||
const imports = new Set<string>();
|
||||
|
||||
const result = await esbuild.build({
|
||||
stdin: { contents: content, loader: "js" },
|
||||
metafile: true,
|
||||
bundle: true,
|
||||
write: false,
|
||||
logLevel: "silent",
|
||||
platform: "neutral",
|
||||
plugins: [
|
||||
{
|
||||
name: "externalize-all",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, (args) => {
|
||||
if (args.kind === "entry-point") {
|
||||
return undefined;
|
||||
}
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const input of Object.values(result.metafile.inputs)) {
|
||||
for (const imp of input.imports) {
|
||||
if (isBareSpecifier(imp.path)) {
|
||||
imports.add(getPackageNameFromSpecifier(imp.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every path string referenced by the package's main/module/exports/bin
|
||||
* fields. These are the entry points that Node.js will actually load when the
|
||||
* package is imported or executed — and thus the surface that needs to have
|
||||
* all of its imports resolvable from `dependencies`/`peerDependencies`.
|
||||
*
|
||||
* Other entries in `files` (e.g. user-facing scaffolding templates) are
|
||||
* intentionally excluded because they aren't loaded by the package itself.
|
||||
*/
|
||||
export function getEntryPointPaths(packageJson: PackageJSON): string[] {
|
||||
const paths = new Set<string>();
|
||||
if (packageJson.main) {
|
||||
paths.add(packageJson.main);
|
||||
}
|
||||
if (packageJson.module) {
|
||||
paths.add(packageJson.module);
|
||||
}
|
||||
walkExportValue(packageJson.exports, paths);
|
||||
if (typeof packageJson.bin === "string") {
|
||||
paths.add(packageJson.bin);
|
||||
} else if (packageJson.bin && typeof packageJson.bin === "object") {
|
||||
for (const v of Object.values(packageJson.bin)) {
|
||||
if (typeof v === "string") {
|
||||
paths.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function walkExportValue(value: unknown, paths: Set<string>): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
paths.add(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
walkExportValue(v, paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const v of Object.values(value as Record<string, unknown>)) {
|
||||
walkExportValue(v, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the package's runtime entry points (and any sibling files in the same
|
||||
* output directories — to cover code-split chunks) and returns the union of
|
||||
* all bare-specifier imports found across them.
|
||||
*/
|
||||
export async function scanDistForExternalImports(
|
||||
packageDir: string,
|
||||
packageJson: PackageJSON
|
||||
): Promise<Set<string>> {
|
||||
const imports = new Set<string>();
|
||||
const entryPaths = getEntryPointPaths(packageJson);
|
||||
|
||||
// Build the set of patterns to scan. For each entry-point path that points
|
||||
// at a JS file, also scan its containing directory recursively to cover
|
||||
// code-split chunks. (E.g. workers-utils' dist/index.mjs re-exports from
|
||||
// dist/chunk-*.mjs, which we want to validate too.)
|
||||
const patterns = new Set<string>();
|
||||
for (const rawPath of entryPaths) {
|
||||
const cleaned = rawPath.replace(/^\.\//, "");
|
||||
if (!/\.(mjs|cjs|js)$/.test(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
const absPath = resolve(packageDir, cleaned);
|
||||
if (!existsSync(absPath)) {
|
||||
continue;
|
||||
}
|
||||
const containingDir = dirname(cleaned);
|
||||
if (containingDir && containingDir !== ".") {
|
||||
patterns.add(`${containingDir}/**/*.{mjs,cjs,js}`);
|
||||
} else {
|
||||
patterns.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
if (patterns.size === 0) {
|
||||
return imports;
|
||||
}
|
||||
|
||||
const matched = await glob([...patterns], {
|
||||
cwd: packageDir,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
for (const file of matched) {
|
||||
if (file.endsWith(".map") || file.endsWith(".d.ts")) {
|
||||
continue;
|
||||
}
|
||||
const content = readFileSync(file, "utf-8");
|
||||
for (const imp of await extractBareImports(content)) {
|
||||
imports.add(imp);
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that every bare-specifier import found in a package's published
|
||||
* files is declared as either a `dependency` or `peerDependency`.
|
||||
*
|
||||
* Catches drift between bundler config `external` lists and `package.json` —
|
||||
* for example, a devDependency that's incorrectly marked external in the
|
||||
* bundler config would leave the published bundle importing an undeclared
|
||||
* runtime dependency.
|
||||
*/
|
||||
export function validateDistImports(
|
||||
packageName: string,
|
||||
packageJson: PackageJSON,
|
||||
importedPackages: Set<string>,
|
||||
ignoredImports: string[] = []
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
const declaredRuntimeDeps = new Set([
|
||||
...getAllDependencies(packageJson.dependencies),
|
||||
...getAllDependencies(packageJson.peerDependencies),
|
||||
]);
|
||||
const devDeps = new Set(getAllDependencies(packageJson.devDependencies));
|
||||
const ignored = new Set(ignoredImports);
|
||||
|
||||
for (const imp of importedPackages) {
|
||||
// A package importing itself by name is always fine — it's a
|
||||
// self-reference inside the bundle (e.g. wrangler's bundled cli.js
|
||||
// contains code that references "wrangler" as a string literal).
|
||||
if (imp === packageName) {
|
||||
continue;
|
||||
}
|
||||
if (declaredRuntimeDeps.has(imp)) {
|
||||
continue;
|
||||
}
|
||||
if (ignored.has(imp)) {
|
||||
continue;
|
||||
}
|
||||
if (devDeps.has(imp)) {
|
||||
errors.push(
|
||||
`Package "${packageName}" imports "${imp}" in its published files, but ` +
|
||||
`"${imp}" is only a devDependency. Either:\n` +
|
||||
` 1. Move "${imp}" to dependencies (and add to scripts/deps.ts if appropriate), or\n` +
|
||||
` 2. Bundle "${imp}" by removing it from the bundler config's "external" list, or\n` +
|
||||
` 3. Add "${imp}" to IGNORED_DIST_IMPORTS in scripts/deps.ts if it's a legitimate optional/try-catch import.`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`Package "${packageName}" imports "${imp}" in its published files, but ` +
|
||||
`"${imp}" is not declared in dependencies or peerDependencies. ` +
|
||||
`Add it to package.json or to IGNORED_DIST_IMPORTS in scripts/deps.ts.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all packages properly declare their external dependencies
|
||||
*/
|
||||
export async function checkPackageDependencies(): Promise<string[]> {
|
||||
const packages = await getPublicPackages();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { dir, packageJson } of packages) {
|
||||
const packageName = packageJson.name;
|
||||
const relativePath = dir.split("/packages/")[1];
|
||||
console.log(`- ${packageName}`);
|
||||
|
||||
const externalDeps = loadExternalDependencies(dir);
|
||||
const packageErrors = validatePackageDependencies(
|
||||
packageName,
|
||||
relativePath,
|
||||
packageJson,
|
||||
externalDeps
|
||||
);
|
||||
errors.push(...packageErrors);
|
||||
|
||||
// Scan the published runtime files (main/module/exports/bin) to catch
|
||||
// drift between bundler `external` lists and `package.json`. This
|
||||
// catches devDeps incorrectly marked as external — which would leave
|
||||
// the published bundle with unresolvable imports for end users.
|
||||
try {
|
||||
const importedPackages = await scanDistForExternalImports(
|
||||
dir,
|
||||
packageJson
|
||||
);
|
||||
const ignoredImports = loadIgnoredDistImports(dir);
|
||||
const distErrors = validateDistImports(
|
||||
packageName,
|
||||
packageJson,
|
||||
importedPackages,
|
||||
ignoredImports
|
||||
);
|
||||
errors.push(...distErrors);
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`Package "${packageName}" dist scan failed: ${e instanceof Error ? e.message : String(e)}.\n` +
|
||||
` Make sure the package is built before running this check.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Validates that all non-bundled dependencies of published packages are pinned
|
||||
* to exact versions.
|
||||
*
|
||||
* Anything a published package does not bundle is installed into the consumer's
|
||||
* dependency tree at install time. If those specifiers are ranges, a malicious
|
||||
* or broken upstream release can be pulled into users' installs without us
|
||||
* having a chance to vet it. Pinning to exact versions locks down exactly what
|
||||
* ships. (devDependencies are intentionally NOT checked: npm never installs a
|
||||
* published package's devDependencies into a consumer's tree, so their
|
||||
* specifiers cannot affect users.)
|
||||
*
|
||||
* The check has two halves:
|
||||
*
|
||||
* 1. Catalog pinning — every entry in the `catalog:` block of
|
||||
* pnpm-workspace.yaml must be an exact version. Because the catalog is
|
||||
* guaranteed pinned, any `catalog:` reference elsewhere is trusted and
|
||||
* doesn't need to be resolved per-package.
|
||||
*
|
||||
* 2. Package pinning — in every published (non-private) package, every
|
||||
* `dependencies` and `optionalDependencies` entry must be an exact
|
||||
* version. Specifiers using `workspace:`, `catalog:`, `npm:`, `link:` or
|
||||
* `file:` are skipped (the catalog ones are covered by half 1; workspace
|
||||
* ones are released atomically with the monorepo). `peerDependencies` are
|
||||
* excluded because ranges there are intentional and generally necessary.
|
||||
*/
|
||||
|
||||
import { loadCatalog } from "./validate-catalog-usage";
|
||||
import {
|
||||
getPublicPackages,
|
||||
type PackageJSON,
|
||||
} from "./validate-package-dependencies";
|
||||
|
||||
/**
|
||||
* Matches an exact semantic version: MAJOR.MINOR.PATCH with optional
|
||||
* `-prerelease` and `+build` metadata (e.g. "1.2.3", "4.1.0-beta.10",
|
||||
* "2.0.0-rc.24"). Rejects ranges (^, ~, >, <, ||, hyphen ranges), wildcards
|
||||
* (*, x), and partial versions.
|
||||
*/
|
||||
const PINNED_VERSION_RE =
|
||||
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
||||
|
||||
/**
|
||||
* Specifier prefixes that are not literal version pins. These are validated
|
||||
* elsewhere (catalog entries by `validateCatalogPins`) or are released
|
||||
* atomically with the monorepo (workspace), so they are skipped here.
|
||||
*/
|
||||
const NON_LITERAL_PREFIXES = [
|
||||
"workspace:",
|
||||
"catalog:",
|
||||
"npm:",
|
||||
"link:",
|
||||
"file:",
|
||||
];
|
||||
|
||||
/**
|
||||
* Catalog entries that are deliberately left as ranges.
|
||||
*
|
||||
* `@cloudflare/workers-types` is consumed as an (optional) peerDependency via
|
||||
* `catalog:default`. A range is intentional there so consumers aren't forced
|
||||
* onto a single exact version of the types package.
|
||||
*/
|
||||
export const CATALOG_PIN_EXCEPTIONS = new Set(["@cloudflare/workers-types"]);
|
||||
|
||||
export function isPinnedVersion(version: string): boolean {
|
||||
return PINNED_VERSION_RE.test(version);
|
||||
}
|
||||
|
||||
function isNonLiteralSpecifier(version: string): boolean {
|
||||
return NON_LITERAL_PREFIXES.some((prefix) => version.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that every catalog entry is pinned to an exact version (except
|
||||
* deliberately-ranged entries in the exceptions allowlist).
|
||||
*/
|
||||
export function validateCatalogPins(
|
||||
catalog: Map<string, string>,
|
||||
exceptions: Set<string> = CATALOG_PIN_EXCEPTIONS
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [name, version] of catalog) {
|
||||
if (exceptions.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (!isPinnedVersion(version)) {
|
||||
errors.push(
|
||||
`Catalog entry "${name}" uses "${version}" but must be pinned to an exact ` +
|
||||
`version in pnpm-workspace.yaml (e.g. "1.2.3", not a range). Catalog entries ` +
|
||||
`can be consumed as non-bundled dependencies, so their versions must be locked down.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a single package's non-bundled dependencies are pinned.
|
||||
* Returns an array of error messages (empty if valid).
|
||||
*/
|
||||
export function validatePackagePins(
|
||||
packageName: string,
|
||||
relativePath: string,
|
||||
packageJson: PackageJSON
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const sections = ["dependencies", "optionalDependencies"] as const;
|
||||
|
||||
for (const section of sections) {
|
||||
const deps = packageJson[section];
|
||||
if (!deps) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (isNonLiteralSpecifier(version)) {
|
||||
continue;
|
||||
}
|
||||
if (!isPinnedVersion(version)) {
|
||||
errors.push(
|
||||
`Package "${packageName}" has ${section} "${name}" set to "${version}" ` +
|
||||
`(packages/${relativePath}/package.json), but non-bundled dependencies must be ` +
|
||||
`pinned to an exact version (e.g. "1.2.3", not a range). If it should be ` +
|
||||
`sourced from the pnpm catalog, use "catalog:default" instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all catalog entries and all published packages' non-bundled
|
||||
* dependencies are pinned to exact versions.
|
||||
*/
|
||||
export async function checkPinnedDependencies(): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
|
||||
console.log("- catalog (pnpm-workspace.yaml)");
|
||||
errors.push(...validateCatalogPins(loadCatalog()));
|
||||
|
||||
const packages = await getPublicPackages();
|
||||
for (const { dir, packageJson } of packages) {
|
||||
const relativePath = dir.split("/packages/")[1];
|
||||
console.log(`- ${packageJson.name}`);
|
||||
errors.push(
|
||||
...validatePackagePins(packageJson.name, relativePath, packageJson)
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking dependency version pinning");
|
||||
checkPinnedDependencies()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Dependency pinning checks:" +
|
||||
errors.map((e) => `\n- ${e}`).join("")
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars -- this script reads CI environment variables */
|
||||
if (require.main === module) {
|
||||
const errors = validateDescription(
|
||||
process.env.TITLE as string,
|
||||
process.env.BODY as string,
|
||||
process.env.LABELS as string,
|
||||
process.env.FILES as string
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
console.error("Validation errors in PR description:");
|
||||
for (const error of errors) {
|
||||
console.error("- ", error);
|
||||
}
|
||||
if (process.env.DRAFT !== "true") {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("These errors must be fixed before you can merge this PR.");
|
||||
console.error(
|
||||
"When you mark this PR as ready for review the CI check will start failing."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDescription(
|
||||
title: string,
|
||||
body: string,
|
||||
labels: string,
|
||||
changedFilesJson: string
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
console.log("PR:", title);
|
||||
|
||||
const parsedLabels = JSON.parse(labels) as string[];
|
||||
|
||||
if (parsedLabels.includes("ci:skip-pr-description-validation")) {
|
||||
console.log(
|
||||
"Skipping validation because the `ci:skip-pr-description-validation` label has been applied"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
/- \[x\] Tests included\/updated/i.test(body) ||
|
||||
/- \[x\] Automated tests not possible - manual testing has been completed as follows:\s*.+/i.test(
|
||||
body
|
||||
) ||
|
||||
/- \[x\] Additional testing not necessary because:\s*.+/i.test(body)
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR must include tests, or provide justification for why no tests are required in the PR description and apply the `ci:no-tests` label"
|
||||
);
|
||||
}
|
||||
|
||||
const changedFiles = JSON.parse(changedFilesJson) as string[];
|
||||
const changesetIncluded = changedFiles.some((f) =>
|
||||
f.startsWith(".changeset/")
|
||||
);
|
||||
|
||||
if (
|
||||
!changesetIncluded &&
|
||||
!parsedLabels.includes("ci:no-changeset-required")
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR doesn't include a changeset. Either include one (following the instructions in CONTRIBUTING.md) or add the 'ci:no-changeset-required' label to bypass this check. Most PRs should have a changeset, so only bypass this check if you're sure that your change doesn't need one: see https://github.com/cloudflare/workers-sdk/blob/main/CONTRIBUTING.md#changesets for more details."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
/- \[x\] Cloudflare docs PR\(s\): https:\/\/(github\.com\/cloudflare\/cloudflare-docs\/(pull|issues)\/\d+|developers\.cloudflare\.com\/.*)/i.test(
|
||||
body
|
||||
) || /- \[x\] Documentation not necessary because: .+/i.test(body)
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
"Your PR must include documentation (in the form of a link to a Cloudflare Docs issue or PR), or provide justification for why no documentation is required"
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
import type { PackageJSON } from "./validate-fixtures";
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking package names");
|
||||
checkPrivatePackageScopes()
|
||||
.then((errors) => {
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Package names checks:" + errors.map((e) => `\n- ${e}`)
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("::endgroup::");
|
||||
console.error("An unexpected error occurred", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPrivatePackageJsons() {
|
||||
const packageJsonPaths = await glob("**/package.json", {
|
||||
cwd: resolve(__dirname, "../../"),
|
||||
ignore: [
|
||||
// We are not interested in dependencies
|
||||
"**/node_modules/**",
|
||||
// The package.jsons in the fixtures directory use the `@fixture/` scope
|
||||
// TODO: consider if we should use the `@cloudflare/` scope instead
|
||||
"fixtures/**",
|
||||
// C3 template package.jsons have placeholder names that are populated by C3 during the app creation
|
||||
"packages/create-cloudflare/templates/**",
|
||||
// The package.jsons in the vite-plugin playground use the `@playground/` scope
|
||||
// TODO: consider if we should use the `@cloudflare/` scope instead
|
||||
"packages/vite-plugin-cloudflare/playground/**",
|
||||
// We are not interested in vendor sub-packages
|
||||
"vendor/**",
|
||||
],
|
||||
});
|
||||
return (
|
||||
packageJsonPaths
|
||||
.map((packageJson) => {
|
||||
const resolved = resolve(packageJson);
|
||||
return {
|
||||
dir: dirname(resolved),
|
||||
packageJson: JSON.parse(
|
||||
readFileSync(packageJson, "utf-8")
|
||||
) as PackageJSON,
|
||||
};
|
||||
})
|
||||
// We are only interested in private packages
|
||||
.filter(({ packageJson }) => packageJson.private)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that private packages are all scoped to `@cloudflare/`.
|
||||
*/
|
||||
async function checkPrivatePackageScopes(): Promise<string[]> {
|
||||
const relevantPackageJsons = await getPrivatePackageJsons();
|
||||
const errors: string[] = [];
|
||||
for (const { dir, packageJson } of relevantPackageJsons) {
|
||||
console.log(`- ${dir}`);
|
||||
|
||||
if (!packageJson.name.startsWith("@cloudflare/")) {
|
||||
errors.push(
|
||||
`Private package in directory "${dir}" has a name that does not start with "@cloudflare/". Please rename it to start with "@cloudflare/".`
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"test:ci": {
|
||||
"inputs": ["../packages/*/package.json"],
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"build": {
|
||||
"env": ["PUBLISHED_PACKAGES"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user