diff --git a/.changeset/fast-experts-shop.md b/.changeset/fast-experts-shop.md new file mode 100644 index 00000000..ca5ea645 --- /dev/null +++ b/.changeset/fast-experts-shop.md @@ -0,0 +1,5 @@ +--- +"wrangler-action": minor +--- + +Support id, environment, url, and alias outputs for Pages deploys. diff --git a/package-lock.json b/package-lock.json index ce685f48..087731f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "wrangler-action", - "version": "3.7.0", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrangler-action", - "version": "3.7.0", + "version": "3.9.0", "license": "MIT OR Apache-2.0", "dependencies": { "@actions/core": "^1.10.1", - "@actions/exec": "^1.1.1" + "@actions/exec": "^1.1.1", + "zod": "^3.23.8" }, "devDependencies": { "@changesets/changelog-github": "^0.4.8", @@ -18,6 +19,7 @@ "@cloudflare/workers-types": "^4.20231121.0", "@types/node": "^20.10.4", "@vercel/ncc": "^0.38.1", + "mock-fs": "^5.4.0", "prettier": "^3.1.0", "semver": "^7.5.4", "typescript": "^5.3.3", @@ -3110,6 +3112,16 @@ "ufo": "^1.3.0" } }, + "node_modules/mock-fs": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.0.tgz", + "integrity": "sha512-3ROPnEMgBOkusBMYQUW2rnT3wZwsgfOKzJDLvx/TZ7FL1WmWvwSwn3j4aDR5fLDGtgcc1WF0Z1y0di7c9L4FKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5011,6 +5023,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 75c1f1b1..09826051 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ }, "dependencies": { "@actions/core": "^1.10.1", - "@actions/exec": "^1.1.1" + "@actions/exec": "^1.1.1", + "zod": "^3.23.8" }, "devDependencies": { "@changesets/changelog-github": "^0.4.8", @@ -39,6 +40,7 @@ "@types/node": "^20.10.4", "@vercel/ncc": "^0.38.1", "prettier": "^3.1.0", + "mock-fs": "^5.4.0", "semver": "^7.5.4", "typescript": "^5.3.3", "vitest": "^1.0.3" diff --git a/src/index.ts b/src/index.ts index df8856e5..f60c63dc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ import { + debug, getBooleanInput, getInput, getMultilineInput, endGroup as originalEndGroup, error as originalError, info as originalInfo, - debug, startGroup as originalStartGroup, setFailed, setOutput, @@ -13,10 +13,11 @@ import { import { getExecOutput } from "@actions/exec"; import semverEq from "semver/functions/eq"; import { exec, execShell } from "./exec"; -import { checkWorkingDirectory, semverCompare } from "./utils"; import { getPackageManager } from "./packageManagers"; +import { checkWorkingDirectory, semverCompare } from "./utils"; +import { getDetailedPagesDeployOutput } from "./wranglerArtifactManager"; -const DEFAULT_WRANGLER_VERSION = "3.78.10"; +const DEFAULT_WRANGLER_VERSION = "3.81.0"; /** * A configuration object that contains all the inputs & immutable state for the action. @@ -313,6 +314,9 @@ async function wranglerCommands() { let stdErr = ""; // Construct the options for the exec command + const wranglerOutputDir = "/opt/wranglerArtifacts"; + process.env.WRANGLER_OUTPUT_FILE_DIRECTORY = wranglerOutputDir; + const options = { cwd: config["workingDirectory"], silent: config["QUIET_MODE"], @@ -333,14 +337,9 @@ async function wranglerCommands() { setOutput("command-output", stdOut); setOutput("command-stderr", stdErr); - // Check if this command is a workers or pages deployment - if ( - command.startsWith("deploy") || - command.startsWith("publish") || - command.startsWith("pages publish") || - command.startsWith("pages deploy") - ) { - // If this is a workers or pages deployment, try to extract the deployment URL + // Check if this command is a workers deployment + if (command.startsWith("deploy") || command.startsWith("publish")) { + // Try to extract the deployment URL let deploymentUrl = ""; const deploymentUrlMatch = stdOut.match(/https?:\/\/[a-zA-Z0-9-./]+/); if (deploymentUrlMatch && deploymentUrlMatch[0]) { @@ -357,6 +356,26 @@ async function wranglerCommands() { setOutput("deployment-alias-url", aliasUrl); } } + // Check if this command is a pages deployment + if ( + command.startsWith("pages publish") || + command.startsWith("pages deploy") + ) { + const pagesArtifactFields = + await getDetailedPagesDeployOutput(wranglerOutputDir); + + if (pagesArtifactFields) { + setOutput("id", pagesArtifactFields.deployment_id); + setOutput("url", pagesArtifactFields.url); + // To ensure parity with pages-action, display url for alias if there is no alias + setOutput("alias", pagesArtifactFields.alias); + setOutput("environment", pagesArtifactFields.environment); + } else { + info( + "No fields available for output. Have you updated wrangler to version >=3.81.0?", + ); + } + } } } finally { endGroup(); diff --git a/src/wranglerArtifactManager.test.ts b/src/wranglerArtifactManager.test.ts new file mode 100644 index 00000000..aff30337 --- /dev/null +++ b/src/wranglerArtifactManager.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + getDetailedPagesDeployOutput, + getWranglerArtifacts, +} from "./wranglerArtifactManager"; + +describe("wranglerArtifactsManager", () => { + const mock = require("mock-fs"); + + describe("getWranglerArtifacts()", async () => { + it("Returns only wrangler output files from a given directory", async () => { + mock({ + testOutputDir: { + "wrangler-output-2024-10-17_18-48-40_463-2e6e83.json": ` + {"version": 1, "type":"wrangler-session", "wrangler_version":"3.81.0", "command_line_args":["what's up"], "log_file_path": "/here"} + {"version": 1, "type":"pages-deploy-detailed", "environment":"production", "alias":"test.com", "deployment_id": "123", "url":"url.com"}`, + "not-wrangler-output.json": "test", + }, + }); + + const artifacts = await getWranglerArtifacts("./testOutputDir"); + + expect(artifacts).toEqual([ + "./testOutputDir/wrangler-output-2024-10-17_18-48-40_463-2e6e83.json", + ]); + mock.restore(); + }); + it("Returns an empty list when the output directory doesn't exist", async () => { + mock({ + notTheDirWeWant: {}, + }); + + const artifacts = await getWranglerArtifacts("./testOutputDir"); + expect(artifacts).toEqual([]); + mock.restore(); + }); + }); + + describe("getDetailedPagesDeployOutput()", async () => { + it("Returns only detailed pages deploy output from wrangler artifacts", async () => { + mock({ + testOutputDir: { + "wrangler-output-2024-10-17_18-48-40_463-2e6e83.json": ` + {"version": 1, "type":"wrangler-session", "wrangler_version":"3.81.0", "command_line_args":["what's up"], "log_file_path": "/here"} + {"version": 1, "type":"pages-deploy-detailed", "pages_project": "project", "environment":"production", "alias":"test.com", "deployment_id": "123", "url":"url.com"}`, + "not-wrangler-output.json": "test", + }, + }); + + const artifacts = await getDetailedPagesDeployOutput("./testOutputDir"); + + expect(artifacts).toEqual({ + version: 1, + pages_project: "project", + type: "pages-deploy-detailed", + url: "url.com", + environment: "production", + deployment_id: "123", + alias: "test.com", + }); + mock.restore(); + }), + it("Skips artifact entries that are not parseable", async () => { + mock({ + testOutputDir: { + "wrangler-output-2024-10-17_18-48-40_463-2e6e83.json": ` + this line is invalid json. + {"version": 1, "type":"pages-deploy-detailed", "pages_project": "project", "environment":"production", "alias":"test.com", "deployment_id": "123", "url":"url.com"}`, + "not-wrangler-output.json": "test", + }, + }); + + const artifacts = await getDetailedPagesDeployOutput("./testOutputDir"); + + expect(artifacts).toEqual({ + version: 1, + type: "pages-deploy-detailed", + pages_project: "project", + url: "url.com", + environment: "production", + deployment_id: "123", + alias: "test.com", + }); + mock.restore(); + }); + }); +}); diff --git a/src/wranglerArtifactManager.ts b/src/wranglerArtifactManager.ts new file mode 100644 index 00000000..f12dc771 --- /dev/null +++ b/src/wranglerArtifactManager.ts @@ -0,0 +1,86 @@ +import { access, open, readdir } from "fs/promises"; +import { z } from "zod"; + +const OutputEntryBase = z.object({ + version: z.literal(1), + type: z.string(), +}); + +const OutputEntryPagesDeployment = OutputEntryBase.merge( + z.object({ + type: z.literal("pages-deploy-detailed"), + pages_project: z.string().nullable(), + deployment_id: z.string().nullable(), + url: z.string().optional(), + alias: z.string().optional(), + environment: z.enum(["production", "preview"]), + }), +); + +type OutputEntryPagesDeployment = z.infer; + +/** + * Parses file names in a directory to find wrangler artifact files + * + * @param artifactDirectory + * @returns All artifact files from the directory + */ +export async function getWranglerArtifacts( + artifactDirectory: string, +): Promise { + try { + await access(artifactDirectory); + } catch { + return []; + } + + // read files in asset directory + const dirent = await readdir(artifactDirectory, { + withFileTypes: true, + recursive: false, + }); + + // Match files to wrangler-output--xxxxxx.json + const regex = new RegExp( + /^wrangler-output-[\d]{4}-[\d]{2}-[\d]{2}_[\d]{2}-[\d]{2}-[\d]{2}_[\d]{3}-[A-Fa-f0-9]{6}\.json$/, + ); + const artifactFilePaths = dirent + .filter((d) => d.name.match(regex)) + .map((d) => `${artifactDirectory}/${d.name}`); + + return artifactFilePaths; +} + +/** + * Searches for detailed wrangler output from a pages deploy + * + * @param artifactDirectory + * @returns The first pages-output-detailed found within a wrangler artifact directory + */ +export async function getDetailedPagesDeployOutput( + artifactDirectory: string, +): Promise { + const artifactFilePaths = await getWranglerArtifacts(artifactDirectory); + + for (let i = 0; i < artifactFilePaths.length; i++) { + const file = await open(artifactFilePaths[i], "r"); + + for await (const line of file.readLines()) { + try { + const output = JSON.parse(line); + const parsedOutput = OutputEntryPagesDeployment.parse(output); + if (parsedOutput.type === "pages-deploy-detailed") { + // Assume, in the context of the action, the first detailed deploy instance seen will suffice + return parsedOutput; + } + } catch (err) { + // If the line can't be parsed, skip it + continue; + } + } + + await file.close(); + } + + return null; +}