diff --git a/.changeset/brave-wasps-greet.md b/.changeset/brave-wasps-greet.md new file mode 100644 index 0000000..98f3a2c --- /dev/null +++ b/.changeset/brave-wasps-greet.md @@ -0,0 +1,5 @@ +--- +"wrangler-action": minor +--- + +This reapplies [303](https://github.com/cloudflare/wrangler-action/pull/303) add parity with pages-action for pages deploy outputs. Thanks @courtney-sims! - Support pages-deployment-id, pages-environment, pages-deployment-alias-url and deployment-url outputs for Pages deploys when wrangler version is >=3.81.0. deployment-alias-url was also deprecated in favour of pages-deployment-alias. diff --git a/README.md b/README.md index 094c68e..97ef9ec 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Now when you run your workflow, you will see the full output of the Wrangler com > Note: the `command-stderr` output variable is also available if you need to parse the standard error output of the Wrangler command. -### Using the `deployment-url` and `deployment-alias-url` Output Variables +### Using the `deployment-url` and `pages-deployment-alias-url` Output Variables If you are executing a Wrangler command that results in either a Workers or Pages deployment, you can utilize the `deployment-url` output variable to get the URL of the deployment. For example, if you want to print the deployment URL after deploying your application, you can do the following: @@ -287,14 +287,14 @@ The resulting output will look something like this: https://.pages.dev ``` -Pages deployments will also provide their alias URL (since Wrangler v3.78.0). You can use the `deployment-alias-url` output variable to get the URL of the deployment alias. This is useful for, for example, branch aliases for preview deployments. +Pages deployments will also provide their alias URL (since Wrangler v3.78.0). You can use the `pages-deployment-alias-url` output variable to get the URL of the deployment alias. This is useful for, for example, branch aliases for preview deployments. If the sample action above was used to deploy a branch other than main, you could use the following to get the branch URL: ```yaml -- name: print deployment-alias-url +- name: print pages-deployment-alias-url env: - DEPLOYMENT_ALIAS_URL: ${{ steps.deploy.outputs.deployment-alias-url }} + DEPLOYMENT_ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} run: echo $DEPLOYMENT_ALIAS_URL ``` diff --git a/action.yml b/action.yml index 6094973..4493360 100644 --- a/action.yml +++ b/action.yml @@ -51,5 +51,9 @@ outputs: description: "The error output of the Wrangler command (comes from stderr)" deployment-url: description: "If the command was a Workers or Pages deployment, this will be the URL of the deployment" - deployment-alias-url: - description: "If the command was a Workers or Pages deployment, this can be the URL of the deployment alias (if it exists) - needs wrangler >= 3.78.0" + pages-deployment-alias-url: + description: "If the command was a Pages deployment, this will be the URL of the deployment alias (if it exists) - needs wrangler >= 3.78.0" + pages-deployment-id: + description: "If the command was a Pages deployment, this will be the ID of the deployment - needs wrangler >= 3.81.0" + pages-environment: + description: "If the command was a Pages deployment, this will be the environment of the deployment - needs wrangler >= 3.81.0" diff --git a/package-lock.json b/package-lock.json index ce685f4..7a44e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "wrangler-action", - "version": "3.7.0", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrangler-action", - "version": "3.7.0", + "version": "3.11.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 fbdcae5..946dea8 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 df8856e..92c6d98 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,13 @@ 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"; +import { join } from "path"; +import { tmpdir } from "os"; -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. @@ -33,6 +36,7 @@ const config = { COMMANDS: getMultilineInput("command"), QUIET_MODE: getBooleanInput("quiet"), PACKAGE_MANAGER: getInput("packageManager"), + WRANGLER_OUTPUT_DIR: `${join(tmpdir(), "wranglerArtifacts")}`, } as const; const packageManager = getPackageManager(config.PACKAGE_MANAGER, { @@ -276,6 +280,29 @@ async function uploadSecrets() { } } +// fallback to trying to extract the deployment-url and pages-deployment-alias-url from stdout for wranglerVersion < 3.81.0 +function extractDeploymentUrlsFromStdout(stdOut: string): { + deploymentUrl?: string; + aliasUrl?: string; +} { + let deploymentUrl = ""; + let aliasUrl = ""; + + // Try to extract the deployment URL + const deploymentUrlMatch = stdOut.match(/https?:\/\/[a-zA-Z0-9-./]+/); + if (deploymentUrlMatch && deploymentUrlMatch[0]) { + deploymentUrl = deploymentUrlMatch[0].trim(); + } + + // And also try to extract the alias URL (since wrangler@3.78.0) + const aliasUrlMatch = stdOut.match(/alias URL: (https?:\/\/[a-zA-Z0-9-./]+)/); + if (aliasUrlMatch && aliasUrlMatch[1]) { + aliasUrl = aliasUrlMatch[1].trim(); + } + + return { deploymentUrl, aliasUrl }; +} + async function wranglerCommands() { startGroup("🚀 Running Wrangler Commands"); try { @@ -312,7 +339,9 @@ async function wranglerCommands() { let stdOut = ""; let stdErr = ""; - // Construct the options for the exec command + // set WRANGLER_OUTPUT_FILE_DIRECTORY env for exec + process.env.WRANGLER_OUTPUT_FILE_DIRECTORY = config.WRANGLER_OUTPUT_DIR; + const options = { cwd: config["workingDirectory"], silent: config["QUIET_MODE"], @@ -333,28 +362,43 @@ async function wranglerCommands() { setOutput("command-output", stdOut); setOutput("command-stderr", stdErr); - // Check if this command is a workers or pages deployment + // Check if this command is a workers deployment + if (command.startsWith("deploy") || command.startsWith("publish")) { + const { deploymentUrl, aliasUrl } = + extractDeploymentUrlsFromStdout(stdOut); + setOutput("deployment-url", deploymentUrl); + // DEPRECATED: deployment-alias-url in favour of pages-deployment-alias, drop in next wrangler-action major version change + setOutput("deployment-alias-url", aliasUrl); + setOutput("pages-deployment-alias-url", aliasUrl); + } + // Check if this command is a 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 - let deploymentUrl = ""; - const deploymentUrlMatch = stdOut.match(/https?:\/\/[a-zA-Z0-9-./]+/); - if (deploymentUrlMatch && deploymentUrlMatch[0]) { - deploymentUrl = deploymentUrlMatch[0].trim(); - setOutput("deployment-url", deploymentUrl); - } - - // And also try to extract the alias URL (since wrangler@3.78.0) - const aliasUrlMatch = stdOut.match( - /alias URL: (https?:\/\/[a-zA-Z0-9-./]+)/, + const pagesArtifactFields = await getDetailedPagesDeployOutput( + config.WRANGLER_OUTPUT_DIR, ); - if (aliasUrlMatch && aliasUrlMatch.length == 2 && aliasUrlMatch[1]) { - const aliasUrl = aliasUrlMatch[1].trim(); + + if (pagesArtifactFields) { + setOutput("deployment-url", pagesArtifactFields.url); + // DEPRECATED: deployment-alias-url in favour of pages-deployment-alias, drop in next wrangler-action major version change + setOutput("deployment-alias-url", pagesArtifactFields.alias); + setOutput("pages-deployment-alias-url", pagesArtifactFields.alias); + setOutput("pages-deployment-id", pagesArtifactFields.deployment_id); + setOutput("pages-environment", pagesArtifactFields.environment); + } else { + info( + "Unable to find a WRANGLER_OUTPUT_DIR, environment and id fields will be unavailable for output. Have you updated wrangler to version >=3.81.0?", + ); + // DEPRECATED: deployment-alias-url in favour of pages-deployment-alias, drop in next wrangler-action major version change + const { deploymentUrl, aliasUrl } = + extractDeploymentUrlsFromStdout(stdOut); + + setOutput("deployment-url", deploymentUrl); + // DEPRECATED: deployment-alias-url in favour of pages-deployment-alias, drop in next wrangler-action major version change setOutput("deployment-alias-url", aliasUrl); + setOutput("pages-deployment-alias-url", aliasUrl); } } } diff --git a/src/wranglerArtifactManager.test.ts b/src/wranglerArtifactManager.test.ts new file mode 100644 index 0000000..00a8840 --- /dev/null +++ b/src/wranglerArtifactManager.test.ts @@ -0,0 +1,85 @@ +import mock from "mock-fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getDetailedPagesDeployOutput, + getWranglerArtifacts, +} from "./wranglerArtifactManager"; + +afterEach(async () => { + mock.restore(); +}); +describe("wranglerArtifactsManager", () => { + 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", + ]); + }); + it("Returns an empty list when the output directory doesn't exist", async () => { + mock({ + notTheDirWeWant: {}, + }); + + const artifacts = await getWranglerArtifacts("./testOutputDir"); + expect(artifacts).toEqual([]); + }); + }); + + 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", + }); + }), + 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", + }); + }); + }); +}); diff --git a/src/wranglerArtifactManager.ts b/src/wranglerArtifactManager.ts new file mode 100644 index 0000000..f12dc77 --- /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; +}