Refactor subprocess execution to use @actions/exec

Instead of using a mix of `child_process.exec`, `child_process.execSync` and a promisified version of `child_process.exec`, we now (mostly) just use `@actions/exec`. That runs `child_process.spawn` under the hood and handles a lot of character escaping for us. We can also now pass Buffers directly into the subprocess as stdin instead of relying on shell piping.

This ends up fixing a few problems we had where secrets and env var values containing shell metacharacters were being misinterpreted.

Unfortunately, `@actions/exec` doesn't support running with a shell. That means we still have to roll our own wrapper around `child_process.exec` to avoid a breaking change to `preCommands` and `postCommands`, since users might be expecting these to run within a shell.

Also worth noting that we're no longer hiding stdout and stderr from the secret uploading step. We were previously doing this out of an abundance of caution, but it made debugging issues very difficult if secret upload failed for some reason. I feel ok doing this since we're no longer echoing & piping the secret values, wrangler doesn't ever output secret values, and as a last line of defense GitHub masks any secret values that accidentally get logged.
This commit is contained in:
Cina Saffary 2023-09-15 01:39:51 -05:00
parent 533097350a
commit d647227bbc
4 changed files with 155 additions and 108 deletions

20
package-lock.json generated
View file

@ -1,15 +1,16 @@
{ {
"name": "wrangler-action", "name": "wrangler-action",
"version": "3.3.0", "version": "3.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wrangler-action", "name": "wrangler-action",
"version": "3.3.0", "version": "3.3.1",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "dependencies": {
"@actions/core": "^1.10.0" "@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@changesets/changelog-github": "^0.4.8", "@changesets/changelog-github": "^0.4.8",
@ -31,6 +32,14 @@
"uuid": "^8.3.2" "uuid": "^8.3.2"
} }
}, },
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"dependencies": {
"@actions/io": "^1.0.1"
}
},
"node_modules/@actions/http-client": { "node_modules/@actions/http-client": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz",
@ -39,6 +48,11 @@
"tunnel": "^0.0.6" "tunnel": "^0.0.6"
} }
}, },
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.22.5", "version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz",

View file

@ -29,7 +29,8 @@
"check": "prettier --check ." "check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.10.0" "@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@changesets/changelog-github": "^0.4.8", "@changesets/changelog-github": "^0.4.8",

54
src/exec.ts Normal file
View file

@ -0,0 +1,54 @@
import {
exec as _childProcessExec,
type ExecException,
} from "node:child_process";
import { EOL } from "node:os";
import { promisify } from "node:util";
export { exec } from "@actions/exec";
const childProcessExec = promisify(_childProcessExec);
type ExecAsyncException = ExecException & {
stderr: string;
stdout: string;
};
function isExecAsyncException(err: unknown): err is ExecAsyncException {
return err instanceof Error && "code" in err && "stderr" in err;
}
export async function execShell(
command: string,
{
silent = false,
...options
}: Parameters<typeof childProcessExec>[1] & { silent?: boolean } = {},
) {
if (!silent) {
process.stdout.write("[command]" + command + EOL);
}
try {
const promise = childProcessExec(command, {
...options,
});
const { child } = promise;
if (!silent) {
child.stdout?.on("data", (data: Buffer) => process.stdout.write(data));
child.stderr?.on("data", (data: Buffer) => process.stderr.write(data));
}
await promise;
return child.exitCode;
} catch (err: any) {
if (isExecAsyncException(err)) {
process.stderr.write(err.stderr);
throw new Error(`Process failed with exit code ${err.code}`);
}
throw err;
}
}

View file

@ -8,11 +8,9 @@ import {
startGroup as originalStartGroup, startGroup as originalStartGroup,
setFailed, setFailed,
} from "@actions/core"; } from "@actions/core";
import { exec, execSync } from "node:child_process"; import { exec, execShell } from "./exec";
import * as util from "node:util";
import { checkWorkingDirectory, semverCompare } from "./utils"; import { checkWorkingDirectory, semverCompare } from "./utils";
import { getPackageManager } from "./packageManagers"; import { getPackageManager } from "./packageManagers";
const execAsync = util.promisify(exec);
const DEFAULT_WRANGLER_VERSION = "3.5.1"; const DEFAULT_WRANGLER_VERSION = "3.5.1";
@ -62,8 +60,8 @@ function endGroup(): void {
async function main() { async function main() {
try { try {
installWrangler();
authenticationSetup(); authenticationSetup();
await installWrangler();
await execCommands(getMultilineInput("preCommands"), "pre"); await execCommands(getMultilineInput("preCommands"), "pre");
await uploadSecrets(); await uploadSecrets();
await wranglerCommands(); await wranglerCommands();
@ -75,36 +73,28 @@ async function main() {
} }
} }
async function runProcess( async function installWrangler() {
command: Parameters<typeof execAsync>[0],
options: Parameters<typeof execAsync>[1],
) {
try {
const result = await execAsync(command, options);
result.stdout && info(result.stdout.toString());
result.stderr && error(result.stderr.toString(), true);
return result;
} catch (err: any) {
err.stdout && info(err.stdout.toString());
err.stderr && error(err.stderr.toString(), true);
throw new Error(`\`${command}\` returned non-zero exit code.`);
}
}
function installWrangler() {
if (config["WRANGLER_VERSION"].startsWith("1")) { if (config["WRANGLER_VERSION"].startsWith("1")) {
throw new Error( throw new Error(
`Wrangler v1 is no longer supported by this action. Please use major version 2 or greater`, `Wrangler v1 is no longer supported by this action. Please use major version 2 or greater`,
); );
} }
startGroup("📥 Installing Wrangler"); startGroup("📥 Installing Wrangler");
const command = `${packageManager.install} wrangler@${config["WRANGLER_VERSION"]}`; try {
info(`Running command: ${command}`); await exec(
execSync(command, { cwd: config["workingDirectory"], env: process.env }); packageManager.install,
info(`✅ Wrangler installed`, true); [`wrangler@${config["WRANGLER_VERSION"]}`],
endGroup(); {
cwd: config["workingDirectory"],
silent: config["QUIET_MODE"],
},
);
info(`✅ Wrangler installed`, true);
} finally {
endGroup();
}
} }
function authenticationSetup() { function authenticationSetup() {
@ -119,28 +109,21 @@ async function execCommands(commands: string[], cmdType: string) {
startGroup(`🚀 Running ${cmdType}Commands`); startGroup(`🚀 Running ${cmdType}Commands`);
try { try {
const arrPromises = commands.map(async (command) => { for (const command of commands) {
const cmd = command.startsWith("wrangler") const cmd = command.startsWith("wrangler")
? `${packageManager.exec} ${command}` ? `${packageManager.exec} ${command}`
: command; : command;
info(`🚀 Executing command: ${cmd}`); await execShell(cmd, {
return await runProcess(cmd, {
cwd: config["workingDirectory"], cwd: config["workingDirectory"],
env: process.env, silent: config["QUIET_MODE"],
}); });
}); }
await Promise.all(arrPromises);
} finally { } finally {
endGroup(); endGroup();
} }
} }
/**
* A helper function to get the secret from the environment variables.
*/
function getSecret(secret: string) { function getSecret(secret: string) {
if (!secret) { if (!secret) {
throw new Error("Secret name cannot be blank."); throw new Error("Secret name cannot be blank.");
@ -148,33 +131,43 @@ function getSecret(secret: string) {
const value = process.env[secret]; const value = process.env[secret];
if (!value) { if (!value) {
throw new Error(`Value for secret ${secret} not found.`); throw new Error(`Value for secret ${secret} not found in environment.`);
} }
return value; return value;
} }
async function legacyUploadSecrets( function getEnvVar(envVar: string) {
if (!envVar) {
throw new Error("Var name cannot be blank.");
}
const value = process.env[envVar];
if (!value) {
throw new Error(`Value for var ${envVar} not found in environment.`);
}
return value;
}
function legacyUploadSecrets(
secrets: string[], secrets: string[],
environment?: string, environment?: string,
workingDirectory?: string, workingDirectory?: string,
) { ) {
const arrPromises = secrets return Promise.all(
.map((secret) => { secrets.map((secret) => {
const command = `echo ${getSecret(secret)} | ${ const args = ["wrangler", "secret", "put", secret];
packageManager.exec if (environment) {
} wrangler secret put ${secret}`; args.push("--env", environment);
return environment ? command.concat(` --env ${environment}`) : command; }
}) return exec(packageManager.exec, args, {
.map( cwd: workingDirectory,
async (command) => silent: config["QUIET_MODE"],
await execAsync(command, { input: Buffer.from(getSecret(secret)),
cwd: workingDirectory, });
env: process.env, }),
}), );
);
await Promise.all(arrPromises);
} }
async function uploadSecrets() { async function uploadSecrets() {
@ -189,51 +182,34 @@ async function uploadSecrets() {
startGroup("🔑 Uploading secrets..."); startGroup("🔑 Uploading secrets...");
try { try {
if (semverCompare(config["WRANGLER_VERSION"], "3.4.0")) if (semverCompare(config["WRANGLER_VERSION"], "3.4.0")) {
return legacyUploadSecrets(secrets, environment, workingDirectory); return legacyUploadSecrets(secrets, environment, workingDirectory);
}
const secretObj = secrets.reduce((acc: any, secret: string) => { const args = ["wrangler", "secret:bulk"];
acc[secret] = getSecret(secret);
return acc;
}, {});
const environmentSuffix = !environment.length if (environment) {
? "" args.push("--env", environment);
: ` --env ${environment}`; }
const secretCmd = `echo "${JSON.stringify(secretObj).replaceAll( await exec(packageManager.exec, args, {
'"',
'\\"',
)}" | ${packageManager.exec} wrangler secret:bulk ${environmentSuffix}`;
execSync(secretCmd, {
cwd: workingDirectory, cwd: workingDirectory,
env: process.env, silent: config["QUIET_MODE"],
stdio: "ignore", input: Buffer.from(
JSON.stringify(
Object.fromEntries(
secrets.map((secret) => [secret, getSecret(secret)]),
),
),
),
}); });
info(`✅ Uploaded secrets`);
} catch (err) { } catch (err) {
error(`❌ Upload failed`);
throw new Error(`Failed to upload secrets.`); throw new Error(`Failed to upload secrets.`);
} finally { } finally {
endGroup(); endGroup();
} }
} }
function getVarArgs() {
const vars = config["VARS"];
const envVarArray = vars.map((envVar: string) => {
if (process.env[envVar] && process.env[envVar]?.length !== 0) {
return `${envVar}:${process.env[envVar]!}`;
} else {
throw new Error(`Value for var ${envVar} not found in environment.`);
}
});
return envVarArray.length > 0 ? `--var ${envVarArray.join(" ").trim()}` : "";
}
async function wranglerCommands() { async function wranglerCommands() {
startGroup("🚀 Running Wrangler Commands"); startGroup("🚀 Running Wrangler Commands");
try { try {
@ -248,27 +224,29 @@ async function wranglerCommands() {
commands.push(deployCommand); commands.push(deployCommand);
} }
const arrPromises = commands.map(async (command) => { for (let command of commands) {
if (environment.length > 0 && !command.includes(`--env`)) { const args = [];
command = command.concat(` --env ${environment}`);
if (environment && !command.includes("--env")) {
args.push("--env", environment);
} }
const cmd = `${packageManager.exec} wrangler ${command} ${ if (
config["VARS"].length &&
(command.startsWith("deploy") || command.startsWith("publish")) && (command.startsWith("deploy") || command.startsWith("publish")) &&
!command.includes(`--var`) !command.includes("--var")
? getVarArgs() ) {
: "" args.push("--var");
}`.trim(); for (const v of config["VARS"]) {
args.push(`${v}:${getEnvVar(v)}`);
}
}
info(`🚀 Executing command: ${cmd}`); await exec(`${packageManager.exec} wrangler ${command}`, args, {
return await runProcess(cmd, {
cwd: config["workingDirectory"], cwd: config["workingDirectory"],
env: process.env, silent: config["QUIET_MODE"],
}); });
}); }
await Promise.all(arrPromises);
} finally { } finally {
endGroup(); endGroup();
} }