diff --git a/README.md b/README.md index f7db9ce0..f412a51c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ The plugin can be configured in the [**semantic-release** configuration file](ht "@semantic-release/exec", { "verifyConditionsCmd": "./verify.sh", + "prepareCmd": { + "cmd": "./prepare.sh ${nextRelease.version} ${nextRelease.gitTag}", + "env": { + "NEXT_RELEASE_NOTES": "${nextRelease.notes}" + } + }, "publishCmd": "./publish.sh ${nextRelease.version} ${branch.name} ${commits.length} ${Date.now()}" } ] @@ -68,7 +74,14 @@ With this example: | `shell` | The shell to use to run the command. See [execa#shell](https://github.com/sindresorhus/execa#shell). | | `execCwd` | The path to use as current working directory when executing the shell commands. This path is relative to the path from which **semantic-release** is running. For example if **semantic-release** runs from `/my-project` and `execCwd` is set to `buildScripts` then the shell command will be executed from `/my-project/buildScripts` | -Each shell command is generated with [Lodash template](https://lodash.com/docs#template). All the [`context` object keys](https://github.com/semantic-release/semantic-release/blob/master/docs/developer-guide/plugin.md#context) passed to semantic-release plugins are available as template options. +Each shell command can be a string or an object with the following properties: + +| Property | Description | +| -------- | ----------------------------------------------------- | +| `cmd` | The shell command to execute. | +| `env` | An object to pass as additional environment variables | + +Each shell command and environment variable value are generated with [Lodash template](https://lodash.com/docs#template). All the [`context` object keys](https://github.com/semantic-release/semantic-release/blob/master/docs/developer-guide/plugin.md#context) passed to semantic-release plugins are available as template options. ## verifyConditionsCmd diff --git a/lib/exec.js b/lib/exec.js index c74f1419..1c1131a3 100644 --- a/lib/exec.js +++ b/lib/exec.js @@ -1,5 +1,5 @@ import { resolve } from "path"; -import { template } from "lodash-es"; +import { template, isNil, isPlainObject, isString } from "lodash-es"; import { execa } from "execa"; export default async function exec( @@ -8,14 +8,24 @@ export default async function exec( { cwd, env, stdout, stderr, logger, ...context }, ) { const cmd = config[cmdProp] ? cmdProp : "cmd"; - const script = template(config[cmd])({ config, ...context }); + + const cmdParsed = parseCommand(config[cmd]); + const script = template(cmdParsed.cmd)({ config, ...context }); + + const envInterpolated = { + ...env, + ...Object.entries(cmdParsed.env).reduce((acc, [key, value]) => { + acc[key] = template(value)({ config, ...context }); + return acc; + }, {}), + }; logger.log("Call script %s", script); const result = execa(script, { shell: shell || true, cwd: execCwd ? resolve(cwd, execCwd) : cwd, - env, + env: envInterpolated, }); result.stdout.pipe(stdout, { end: false }); @@ -23,3 +33,11 @@ export default async function exec( return (await result).stdout.trim(); } + +function parseCommand(cmd) { + if (isString(cmd)) { + return { cmd, env: {} }; + } else if (isPlainObject(cmd) && !isNil(cmd.cmd)) { + return { cmd: cmd.cmd, env: cmd.env || {} }; + } +} diff --git a/lib/verify-config.js b/lib/verify-config.js index a7695c29..01568516 100644 --- a/lib/verify-config.js +++ b/lib/verify-config.js @@ -1,12 +1,21 @@ -import { isNil, isString } from "lodash-es"; +import { isNil, isPlainObject, isString } from "lodash-es"; import AggregateError from "aggregate-error"; import getError from "./get-error.js"; const isNonEmptyString = (value) => isString(value) && value.trim(); const isOptional = (validator) => (value) => isNil(value) || validator(value); +const isCmdWithEnv = (value) => + isPlainObject(value) && + isNonEmptyString(value.cmd) && + isOptional(isObjectOfStrings)(value.env); +const isStringOrCmdWithEnv = (value) => + isNonEmptyString(value) || isCmdWithEnv(value); +const isObjectOfStrings = (value) => + isPlainObject(value) && + Object.values(value).every((element) => isString(element)); const VALIDATORS = { - cmd: isNonEmptyString, + cmd: isStringOrCmdWithEnv, shell: isOptional((shell) => shell === true || isNonEmptyString(shell)), execCwd: isOptional(isNonEmptyString), }; diff --git a/test/exec.test.js b/test/exec.test.js index 3d80256e..87dd0e0c 100644 --- a/test/exec.test.js +++ b/test/exec.test.js @@ -51,6 +51,28 @@ test("Generate command with template", async (t) => { t.is(result, "confValue 1.0.0"); }); +test("Generate command with env variables", async (t) => { + const pluginConfig = { + publishCmd: { + cmd: `./test/fixtures/echo-args.sh \${config.conf} \${lastRelease.version}`, + env: { + NEXT_RELEASE_NOTES: "${nextRelease.notes}", + }, + }, + conf: "confValue", + }; + const context = { + stdout: t.context.stdout, + stderr: t.context.stderr, + lastRelease: { version: "1.0.0" }, + nextRelease: { notes: "notes &\"'" }, + logger: t.context.logger, + }; + + const result = await exec("publishCmd", pluginConfig, context); + t.is(result, "confValue 1.0.0 notes &\"'"); +}); + test('Execute the script with the specified "shell"', async (t) => { const context = { stdout: t.context.stdout, diff --git a/test/fixtures/echo-args.sh b/test/fixtures/echo-args.sh index 7a0bbbbf..cc7a8c72 100755 --- a/test/fixtures/echo-args.sh +++ b/test/fixtures/echo-args.sh @@ -1,2 +1,2 @@ #!/bin/bash -echo "$@" +echo "$@" "$NEXT_RELEASE_NOTES" diff --git a/test/verify-config.test.js b/test/verify-config.test.js index 0b6910fd..2863664a 100644 --- a/test/verify-config.test.js +++ b/test/verify-config.test.js @@ -21,6 +21,91 @@ test('Verify "cmd", "shell" and "execCwd" options', (t) => { t.notThrows(() => verify("verifyConditionsCmd", { cmd: "shell cmd" })); + t.notThrows(() => + verify("verifyConditionsCmd", { + verifyConditionsCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("analyzeCommitsCmd", { + analyzeCommitsCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("verifyReleaseCmd", { + verifyReleaseCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("generateNotesCmd", { + generateNotesCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("prepareCmd", { + prepareCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("publishCmd", { + publishCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("successCmd", { + successCmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + t.notThrows(() => + verify("failCmd", { failCmd: { cmd: "shell cmd", env: { var: "value" } } }), + ); + + t.notThrows(() => + verify("verifyConditionsCmd", { + cmd: { cmd: "shell cmd", env: { var: "value" } }, + }), + ); + + t.notThrows(() => + verify("verifyConditionsCmd", { + cmd: "shell cmd", + shell: true, + execCwd: "scripts", + }), + ); + t.notThrows(() => + verify("verifyConditionsCmd", { + cmd: "shell cmd", + shell: "bash", + execCwd: "scripts", + }), + ); +}); + +test('Verify "cmd" options with missing env', (t) => { + t.notThrows(() => + verify("verifyConditionsCmd", { + verifyConditionsCmd: { cmd: "shell cmd" }, + }), + ); + t.notThrows(() => + verify("analyzeCommitsCmd", { analyzeCommitsCmd: { cmd: "shell cmd" } }), + ); + t.notThrows(() => + verify("verifyReleaseCmd", { verifyReleaseCmd: { cmd: "shell cmd" } }), + ); + t.notThrows(() => + verify("generateNotesCmd", { generateNotesCmd: { cmd: "shell cmd" } }), + ); + t.notThrows(() => verify("prepareCmd", { prepareCmd: { cmd: "shell cmd" } })); + t.notThrows(() => verify("publishCmd", { publishCmd: { cmd: "shell cmd" } })); + t.notThrows(() => verify("successCmd", { successCmd: { cmd: "shell cmd" } })); + t.notThrows(() => verify("failCmd", { failCmd: { cmd: "shell cmd" } })); + + t.notThrows(() => + verify("verifyConditionsCmd", { cmd: { cmd: "shell cmd" } }), + ); + t.notThrows(() => verify("verifyConditionsCmd", { cmd: "shell cmd", @@ -83,6 +168,50 @@ test('Throw SemanticReleaseError if "cmd" option is not a String', (t) => { [error] = t.throws(() => verify("verifyConditionsCmd", { cmd: 1 })); t.is(error.name, "SemanticReleaseError"); t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("verifyConditionsCmd", { cmd: { cmd: 1 } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyConditionsCmd", { verifyConditionsCmd: { cmd: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("analyzeCommitsCmd", { analyzeCommitsCmd: { cmd: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyReleaseCmd", { verifyReleaseCmd: { cmd: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("generateNotesCmd", { generateNotesCmd: { cmd: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("prepareCmd", { prepareCmd: { cmd: 1 } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("publishCmd", { publishCmd: { cmd: 1 } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("successCmd", { successCmd: { cmd: 1 } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("failCmd", { failCmd: { cmd: 1 } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); }); test('Throw SemanticReleaseError if "cmd" option is an empty String', (t) => { @@ -129,6 +258,194 @@ test('Throw SemanticReleaseError if "cmd" option is an empty String', (t) => { [error] = t.throws(() => verify("verifyConditionsCmd", { cmd: " " })); t.is(error.name, "SemanticReleaseError"); t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyConditionsCmd", { verifyConditionsCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("analyzeCommitsCmd", { analyzeCommitsCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyReleaseCmd", { verifyReleaseCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("generateNotesCmd", { generateNotesCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("prepareCmd", { prepareCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("publishCmd", { publishCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("successCmd", { successCmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => verify("failCmd", { failCmd: { cmd: " " } })); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyConditionsCmd", { cmd: { cmd: " " } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); +}); + +test('Throw SemanticReleaseError if "cmd" option has an invalid env', (t) => { + let [error] = t.throws(() => + verify("verifyConditionsCmd", { + verifyConditionsCmd: { cmd: "shell cmd", env: 1 }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("analyzeCommitsCmd", { + analyzeCommitsCmd: { cmd: "shell cmd", env: 1 }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyReleaseCmd", { + verifyReleaseCmd: { cmd: "shell cmd", env: 1 }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("generateNotesCmd", { + generateNotesCmd: { cmd: "shell cmd", env: 1 }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("prepareCmd", { prepareCmd: { cmd: "shell cmd", env: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("publishCmd", { publishCmd: { cmd: "shell cmd", env: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("successCmd", { successCmd: { cmd: "shell cmd", env: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("failCmd", { failCmd: { cmd: "shell cmd", env: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyConditionsCmd", { cmd: { cmd: "shell cmd", env: 1 } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); +}); + +test('Throw SemanticReleaseError if "cmd" option has an non string envs', (t) => { + let [error] = t.throws(() => + verify("verifyConditionsCmd", { + verifyConditionsCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("analyzeCommitsCmd", { + analyzeCommitsCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyReleaseCmd", { + verifyReleaseCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("generateNotesCmd", { + generateNotesCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("prepareCmd", { + prepareCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("publishCmd", { + publishCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("successCmd", { + successCmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("failCmd", { failCmd: { cmd: "shell cmd", env: { invalid: 1 } } }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); + + [error] = t.throws(() => + verify("verifyConditionsCmd", { + cmd: { cmd: "shell cmd", env: { invalid: 1 } }, + }), + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDCMD"); }); test('Throw SemanticReleaseError if "shell" option is not a String or "true"', (t) => {