| 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 |