返回 CodeWhale
deploy-preflight.test.ts
根目录 / web / lib / deploy-preflight.test.ts
1 import { spawnSync } from "node:child_process";
2 import { readFileSync } from "node:fs";
3 import { fileURLToPath } from "node:url";
4 import { describe, expect, it } from "vitest";
5
6 // fileURLToPath, not `.pathname`: the latter stays percent-encoded, so a
7 // checkout under a path with non-ASCII characters spawns a filename that
8 // does not exist and every case here fails on a module-not-found exit.
9 const script = fileURLToPath(new URL("../scripts/check-cloudflare-deploy-env.mjs", import.meta.url));
10
11 function run(overrides: Record<string, string>, args: string[] = []) {
12 return spawnSync(process.execPath, [script, ...args], {
13 encoding: "utf8",
14 env: {
15 ...process.env,
16 GITHUB_ACTIONS: "",
17 GITHUB_EVENT_NAME: "",
18 GITHUB_REF: "",
19 GITHUB_SHA: "",
20 CLOUDFLARE_ACCOUNT_ID: "",
21 CLOUDFLARE_API_TOKEN: "",
22 ...overrides,
23 },
24 });
25 }
26
27 describe("Cloudflare deploy preflight", () => {
28 it("reports intentionally withheld credentials without deploying", () => {
29 const result = run({}, ["--preflight"]);
30
31 expect(result.status).toBe(0);
32 expect(result.stdout).toContain("credentialState\":\"withheld");
33 expect(result.stdout).toContain("deploymentStarted\":false");
34 });
35
36 it("rejects malformed supplied values even in credential-free preflight mode", () => {
37 const result = run(
38 {
39 CLOUDFLARE_ACCOUNT_ID: "not-an-account-id",
40 CLOUDFLARE_API_TOKEN: "not-a-token",
41 },
42 ["--preflight"],
43 );
44
45 expect(result.status).toBe(1);
46 expect(result.stderr).toContain("malformed credential placeholders");
47 expect(result.stdout).toContain("credentialState\":\"invalid");
48 });
49
50 it("keeps the normal deploy check fail-closed when credentials are missing", () => {
51 const result = run({});
52
53 expect(result.status).toBe(1);
54 expect(result.stderr).toContain("Cloudflare deploy configuration is incomplete");
55 });
56
57 it("requires an exact manual-main context inside GitHub Actions", () => {
58 const result = run({
59 GITHUB_ACTIONS: "true",
60 GITHUB_EVENT_NAME: "push",
61 GITHUB_REF: "refs/heads/main",
62 GITHUB_SHA: "a".repeat(40),
63 CLOUDFLARE_ACCOUNT_ID: "a".repeat(32),
64 CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32),
65 });
66
67 expect(result.status).toBe(1);
68 expect(result.stderr).toContain("workflow_dispatch on refs/heads/main");
69 });
70
71 it("rejects a dispatch on a non-main ref", () => {
72 const result = run({
73 GITHUB_ACTIONS: "true",
74 GITHUB_EVENT_NAME: "workflow_dispatch",
75 GITHUB_REF: "refs/heads/release",
76 GITHUB_SHA: "a".repeat(40),
77 CLOUDFLARE_ACCOUNT_ID: "a".repeat(32),
78 CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32),
79 });
80
81 expect(result.status).toBe(1);
82 expect(result.stderr).toContain("workflow_dispatch on refs/heads/main");
83 });
84
85 it("rejects a dispatch without an exact 40-hex revision", () => {
86 const result = run({
87 GITHUB_ACTIONS: "true",
88 GITHUB_EVENT_NAME: "workflow_dispatch",
89 GITHUB_REF: "refs/heads/main",
90 GITHUB_SHA: "main",
91 CLOUDFLARE_ACCOUNT_ID: "a".repeat(32),
92 CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32),
93 });
94
95 expect(result.status).toBe(1);
96 expect(result.stderr).toContain("exact SHA");
97 });
98
99 it("accepts a manual dispatch on main at an exact SHA", () => {
100 const result = run({
101 GITHUB_ACTIONS: "true",
102 GITHUB_EVENT_NAME: "workflow_dispatch",
103 GITHUB_REF: "refs/heads/main",
104 GITHUB_SHA: "a".repeat(40),
105 CLOUDFLARE_ACCOUNT_ID: "a".repeat(32),
106 CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32),
107 });
108
109 expect(result.status).toBe(0);
110 expect(result.stdout).toContain("Cloudflare deploy environment is present");
111 });
112 });
113
114 // Minimal, dependency-free reader for the two-space-indented job blocks in
115 // .github/workflows/web.yml. A real YAML parser is not a web dependency, and
116 // this file only needs the `on:` triggers plus the deploy job's `if:` guard.
117 function readWebWorkflow() {
118 const path = new URL("../../.github/workflows/web.yml", import.meta.url);
119 return readFileSync(path, "utf8");
120 }
121
122 function jobBlock(source: string, job: string) {
123 const lines = source.split("\n");
124 const start = lines.findIndex((line) => line === ` ${job}:`);
125 expect(start, `job ${job} not found in web.yml`).toBeGreaterThanOrEqual(0);
126 const rest = lines.slice(start + 1);
127 const end = rest.findIndex((line) => /^ {2}\S/.test(line));
128 return (end === -1 ? rest : rest.slice(0, end)).join("\n");
129 }
130
131 describe("web workflow deploy trigger contract", () => {
132 const workflow = readWebWorkflow();
133 const deploy = jobBlock(workflow, "deploy");
134 const deployReminder = jobBlock(workflow, "deploy-reminder");
135
136 it("still runs lint on pushes and pull requests", () => {
137 expect(workflow).toContain(" push:\n branches: [master, main]");
138 expect(workflow).toContain(" pull_request:\n branches: [master, main]");
139 expect(workflow).toContain(" workflow_dispatch:");
140 expect(jobBlock(workflow, "lint")).not.toContain("if:");
141 });
142
143 it("gates deploy on a manual dispatch of main only", () => {
144 const guard = deploy
145 .slice(deploy.indexOf("if:"))
146 .split("\n")
147 .slice(0, 3)
148 .join(" ")
149 .replace(/\s+/g, " ");
150
151 expect(guard).toContain("github.event_name == 'workflow_dispatch'");
152 expect(guard).toContain("github.ref == 'refs/heads/main'");
153 // The preflight script fails closed on any non-dispatch event, so a push
154 // trigger here could only ever produce a red deploy job (#4907).
155 expect(guard).not.toContain("'push'");
156 expect(deploy).toContain("needs: lint");
157 });
158
159 it("surfaces an actionable deployment reminder after a green main push", () => {
160 expect(deployReminder).toContain("needs: lint");
161 expect(deployReminder).toContain(
162 "github.event_name == 'push' && github.ref == 'refs/heads/main'",
163 );
164 expect(deployReminder).toContain("::notice title=Web deployment approval needed::");
165 expect(deployReminder).toContain("gh workflow run web.yml");
166 expect(deployReminder).not.toContain("npm run deploy");
167 });
168
169 it("checks out the exact dispatched revision before deploying", () => {
170 expect(deploy).toContain("ref: ${{ github.sha }}");
171 expect(deploy).toContain('--expected-revision "$GITHUB_SHA"');
172 });
173
174 it("builds one OpenNext bundle before preview or deploy without a Wrangler rebuild", () => {
175 const packageJson = JSON.parse(
176 readFileSync(new URL("../package.json", import.meta.url), "utf8"),
177 ) as { scripts: Record<string, string> };
178 const wrangler = JSON.parse(
179 readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8"),
180 ) as { build?: { command?: string } };
181
182 expect(packageJson.scripts.preview).toBe(
183 "opennextjs-cloudflare build && opennextjs-cloudflare preview",
184 );
185 expect(packageJson.scripts.deploy).toBe(
186 "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
187 );
188 expect(wrangler.build).toBeUndefined();
189 expect(deploy).toContain("run: npm run deploy");
190 expect(deploy).not.toContain("npm run build");
191 expect(deploy).not.toContain("npx opennextjs-cloudflare build");
192 });
193 });
194
194 lines TYPESCRIPT