diff --git a/README.md b/README.md index 659ea77e77170c2ccae6dfbda910f8bdd0eb2d3d..fc2fcf370841562be1358ec3943b916feffd68da 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # yq -A `semantic-release` plugin to update JSON, YAML, TOML and more files with [`yq`]. +A `semantic-release` plugin to update JSON, YAML, TOML and more files with +[`yq`]. -| Step | Description | -| ---- | ----------- | +| Step | Description | +| ---------------- | ------------------------------------------------------------------- | | verifyConditions | Verify that each file is writable and the `yq` expression is valid. | -| prepare | Updates each file in-place with the `yq` expression provided. | +| prepare | Updates each file in-place with the `yq` expression provided. | ## Getting Started @@ -79,6 +80,21 @@ plugins: .version = "${nextRelease.version}" ``` +### `assets[*].frontMatter` + +Determines if the `expression` should operate on the YAML [front +matter][front-matter]. + +```yaml +plugins: + - path: "@semantic-release/yq" + assets: + - filepath: "./some/file.yaml" + frontMatter: "process" + expression: |- + .version = "${nextRelease.version}" +``` + ### `yq` The NPM package contains a certain set of hermetic `yq` binaries. @@ -86,18 +102,22 @@ The NPM package contains a certain set of hermetic `yq` binaries. To keep the package size small, not _all_ `yq` binaries are provided. Options are: - - Provide a `yq` binaries on the `$PATH` - - Set the `yq` configuration variable - ```yaml - plugins: - - path: "@semantic-release/yq" - yq: "/usr/bin/yq" - ``` - - Request for more `yq` binaries to be included in the package + +- Provide a `yq` binaries on the `$PATH` +- Set the `yq` configuration variable + ```yaml + plugins: + - path: "@semantic-release/yq" + yq: "/usr/bin/yq" + ``` +- Request for more `yq` binaries to be included in the package Binaries are included in the package with a `prepublish.mjs` download script. [yq]: https://github.com/mikefarah/yq -[quick-usage-guide]: https://github.com/mikefarah/yq?tab=readme-ov-file#quick-usage-guide +[quick-usage-guide]: + https://github.com/mikefarah/yq?tab=readme-ov-file#quick-usage-guide [lodash template]: https://docs-lodash.com/v4/template/ -[substitutions]: https://semantic-release.gitbook.io/semantic-release/developer-guide/js-api#nextrelease +[substitutions]: + https://semantic-release.gitbook.io/semantic-release/developer-guide/js-api#nextrelease +[front-matter]: https://mikefarah.gitbook.io/yq/usage/front-matter diff --git a/argument.mjs b/argument.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9aff64e7839d03672e09bc1bf21d5b6beb8d245a --- /dev/null +++ b/argument.mjs @@ -0,0 +1,57 @@ +import {template} from 'lodash-es'; + +export default class Argument { + #argument; + #value; + + constructor(...args) { + switch (args.length) { + case 1: { + [this.#value] = args; + break; + } + + case 2: { + [this.#argument, this.#value] = args; + break; + } + + default: { + throw new Error(`Unsupported number of arguments: ${args}`); + } + } + } + + get argument() { + return this.#argument; + } + + get value() { + return this.#value; + } + + [Symbol.toPrimitive]() { + return this.value; + } + + get template() { + if (this.value === undefined) { + return () => undefined; + } + + return template(this.value); + } + + async render(ctx) { + const rendered = this.template(ctx); + if (rendered === undefined) { + return []; + } + + if (this.argument !== undefined) { + return [`${this.argument}`, `${rendered}`]; + } + + return `${rendered}`; + } +} diff --git a/asset.mjs b/asset.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4f57184c50dec03862066bafd06f0fb22a40c36f --- /dev/null +++ b/asset.mjs @@ -0,0 +1,57 @@ +import SemanticReleaseError from '@semantic-release/error'; +import FilePath from './file-path.mjs'; +import Expression from './expression.mjs'; +import FrontMatter from './front-matter.mjs'; + +export default class Asset { + #expression; + #filepath; + #frontMatter; + + constructor(value) { + if (typeof value !== 'object' || Array.isArray(value)) { + throw new SemanticReleaseError( + 'An asset must be an object', + 'EYQCFG', + `${value} (${typeof value})`, + ); + } + + const {expression, filepath, frontMatter} = value; + this.#expression = new Expression(expression); + this.#filepath = new FilePath(filepath); + this.#frontMatter = new FrontMatter(frontMatter); + } + + get expression() { + return this.#expression; + } + + get filepath() { + return this.#filepath; + } + + get frontMatter() { + return this.#frontMatter; + } + + [Symbol.iterator] = function * () { + yield this.frontMatter; + yield this.expression; + yield this.filepath; + }; + + get #array() { + return Array.from(this); + } + + map(...args) { + return this.#array.map(...args); + } + + async render(ctx) { + const promises = this.map(a => a.render(ctx)); + const values = await Promise.all(promises); + return values.flat(); + } +} diff --git a/assets.mjs b/assets.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b8427746dd601247fc5781019920fcbb96f7fb37 --- /dev/null +++ b/assets.mjs @@ -0,0 +1,32 @@ +import SemanticReleaseError from '@semantic-release/error'; +import Asset from './asset.mjs'; + +export default class Assets { + #value; + + constructor(value) { + if (!Array.isArray(value)) { + throw new SemanticReleaseError( + '`assets` must be an array', + 'EYQCFG', + `${value} (${typeof value})`, + ); + } + + this.#value = value; + } + + [Symbol.iterator] = function * () { + for (const asset of this.#value) { + yield new Asset(asset); + } + }; + + get #array() { + return Array.from(this); + } + + map(...args) { + return this.#array.map(...args); + } +} diff --git a/expression.mjs b/expression.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ce6515ea59746956e6035365e98979a861d4bba2 --- /dev/null +++ b/expression.mjs @@ -0,0 +1,29 @@ +import SemanticReleaseError from '@semantic-release/error'; +import Argument from './argument.mjs'; + +export default class Expression extends Argument { + constructor(value) { + if (typeof value !== 'string') { + throw new SemanticReleaseError( + '`asset.expression` must be a string', + 'EYQCFG', + `${value} (${typeof value})`, + ); + } + + super('--expression', value); + } + + async render(ctx) { + try { + return await super.render(ctx); + } catch (error) { + throw new SemanticReleaseError( + '`asset.expression` failed to be templated', + 'EYQCFG', + `${error}`, + ); + } + } +} + diff --git a/file-path.mjs b/file-path.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e2f075f9840505462b07b39346546fa60c380419 --- /dev/null +++ b/file-path.mjs @@ -0,0 +1,48 @@ +import {access, constants} from 'node:fs/promises'; +import path from 'node:path'; +import SemanticReleaseError from '@semantic-release/error'; +import Argument from './argument.mjs'; + +export default class FilePath extends Argument { + constructor(value) { + if (typeof value !== 'string') { + throw new SemanticReleaseError( + '`asset.filepath` must be a string', + 'EYQCFG', + `${value} (${typeof value})`, + ); + } + + super(value); + } + + async #rendered(ctx) { + try { + return await super.render(ctx); + } catch (error) { + throw new SemanticReleaseError( + '`asset.filepath` failed to be templated', + 'EYQCFG', + `${error}`, + ); + } + } + + async render(ctx) { + const location = path.join(ctx.cwd, await this.#rendered(ctx)); + + try { + // eslint-disable-next-line no-bitwise + await access(location, constants.R_OK | constants.W_OK); + } catch (error) { + throw new SemanticReleaseError( + `Insufficient file access for \`${this.value}\``, + 'EYQCFG', + `${error}`, + ); + } + + return location; + } +} + diff --git a/front-matter.mjs b/front-matter.mjs new file mode 100644 index 0000000000000000000000000000000000000000..88e05254ba76c0764bf4787011e7ed19f5b145b2 --- /dev/null +++ b/front-matter.mjs @@ -0,0 +1,26 @@ +import SemanticReleaseError from '@semantic-release/error'; +import Argument from './argument.mjs'; + +export default class FrontMatter extends Argument { + static choices = Object.freeze(['extract', 'process']); + + constructor(value) { + if (value === undefined) { + // Pass + } else if (typeof value !== 'string') { + throw new SemanticReleaseError( + '`asset.frontMatter` must be a string', + 'EYQCFG', + `${value} (${typeof value})`, + ); + } else if (!FrontMatter.choices.includes(value)) { + throw new SemanticReleaseError( + `\`asset.frontMatter\` must be one of ${FrontMatter.choices.map(s => `\`${s}\``)}`, + 'EYQCFG', + ); + } + + super('--front-matter', value); + } +} + diff --git a/package-lock.json b/package-lock.json index 06bf7b19884ea13157a6da9518ae0e5e7c3db773..eb83bac36c94003c69db10bcdc58abf6218d09b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@commitlint/config-conventional": "^18", "@semantic-release/changelog": "^6", "@semantic-release/commit-analyzer": "^11", - "@semantic-release/config-gitlab-npm": "^1", + "@semantic-release/config-gitlab-npm": "^1.1.2", "@semantic-release/config-release-channels": "^1", "@semantic-release/exec": "^6", "@semantic-release/git": "^10", @@ -1228,11 +1228,10 @@ } }, "node_modules/@semantic-release/config-gitlab-npm": { - "version": "1.1.0", - "resolved": "https://gitlab.arm.com/semantic-release/config-gitlab-npm/-/releases/v1.1.0/downloads/package.tar.gz", - "integrity": "sha512-Cejn62E96XGK5q40exRqI56TLVRbRKX6DXW9fBpaKqeCDl37w2ohuHWa/eaI6Q+LihQ5ILkBVeQSqfzwISzePQ==", + "version": "1.1.2", + "resolved": "https://gitlab.arm.com/api/v4/projects/407/packages/npm/@semantic-release/config-gitlab-npm/-/@semantic-release/config-gitlab-npm-1.1.2.tgz", + "integrity": "sha1-FBSPpOPyuDm62dtMBDZDmJT/gFA=", "dev": true, - "license": "MIT", "peerDependencies": { "@semantic-release/changelog": ">=4", "@semantic-release/commit-analyzer": ">=9", diff --git a/package.json b/package.json index afae142ab2b65cf1f13d86e431239858587152de..9c350bafe81d123ffbf5782012b89e9bde655544 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "modules": "plugin.mjs", "files": [ "plugin.js", - "plugin.mjs", + "*.mjs", "bin/*" ], "scripts": { @@ -77,9 +77,12 @@ "c8": { "100": true, "include": [ - "plugin.mjs", + "*.mjs", "plugin.js" ], + "exclude": [ + "prepublish.mjs" + ], "reporter": [ "text", "html", diff --git a/plugin.mjs b/plugin.mjs index 5be0e796c88d1a02800ef99ae0bdf86682e75d16..d3f490121e367cad150727d4b8f5fc3ae4897239 100644 --- a/plugin.mjs +++ b/plugin.mjs @@ -1,61 +1,20 @@ -import {access, constants, readdir} from 'node:fs/promises'; import os from 'node:os'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; import SemanticReleaseError from '@semantic-release/error'; import debug from 'debug'; -import {template} from 'lodash-es'; import {$} from 'execa'; -import which from 'which'; +import find, {bins as available} from './yq.mjs'; +import Assets from './assets.mjs'; debug('semantic-release:yq'); -const BIN = (() => { - const file = fileURLToPath(import.meta.url); - const dirname = path.dirname(file); - const bin = path.join(dirname, 'bin'); - return bin; -})(); - -const YQ = await (async () => { - const arch = { - arm: 'arm', - arm64: 'arm64', - ppc64: 'ppc64', - x64: 'amd64', - s390: 's390', - s390x: 's390x', - mips: 'mips', - riscv64: 'riscv64', - }[os.arch()]; - const platform = { - darwin: 'darwin', - linux: 'linux', - freebsd: 'freebsd', - openbsd: 'openbsd', - netbsd: 'netbsd', - win32: 'windows', - }[os.platform()]; - const extension = platform === 'windows' ? /* c8 ignore next */ '.exe' : ''; - const basename = `yq_${platform}_${arch}${extension}`; - const filepath = path.join(BIN, basename); - try { - await access(filepath, constants.X_OK); - return filepath; - /* c8 ignore next 3 */ - } catch { - return which('yq').catch(() => undefined); - } -})(); - export async function verifyConditions(pluginConfig, context) { - const {assets, yq = YQ} = pluginConfig; - const {cwd, logger} = context; + const {assets: value, yq = await find()} = pluginConfig; + const {logger, ...rest} = context; /* c8 ignore next */ if (yq === undefined) { /* c8 ignore next 6 */ - const bins = await readdir(BIN).catch(() => []); + const bins = await available().catch(() => []); throw new SemanticReleaseError( 'No `yq` binary available', 'EYQCFG', @@ -69,70 +28,27 @@ export async function verifyConditions(pluginConfig, context) { ); } - if (!Array.isArray(assets)) { - throw new SemanticReleaseError( - '`assets` must be an array', - 'EYQCFG', - `${assets} (${typeof assets})`, - ); - } + const assets = new Assets(value); - const checks = assets.map(({filepath, expression}) => (async () => { - if (typeof filepath !== 'string') { - throw new SemanticReleaseError( - '`asset.filepath` must be a string', - 'EYQCFG', - `${filepath} (${typeof filepath})`, - ); - } - - if (typeof expression !== 'string') { - throw new SemanticReleaseError( - '`asset.expression` must be a string', - 'EYQCFG', - `${expression} (${typeof expression})`, - ); - } - - const location = path.join(cwd, filepath); + const ctx = { + nextRelease: { + type: 'patch', + version: '0.0.0', + gitHead: '0123456789abcedf0123456789abcdef12345678', + gitTag: 'v0.0.0', + notes: 'placeholder', + }, + ...rest, + }; + const checks = assets.map(async asset => { try { - // eslint-disable-next-line no-bitwise - await access(location, constants.R_OK | constants.W_OK); + await $`${yq} ${await asset.render(ctx)}`; } catch (error) { - throw new SemanticReleaseError( - `Insufficient file access for \`${filepath}\``, - 'EYQCFG', - `${error}`, - ); - } - - const ctx = { - nextRelease: { - type: 'patch', - version: '0.0.0', - gitHead: '0123456789abcedf0123456789abcdef12345678', - gitTag: 'v0.0.0', - notes: 'placeholder', - }, - ...context, - }; - - const rendered = (() => { - try { - return template(expression)(ctx); - } catch (error) { - throw new SemanticReleaseError( - '`asset.expression` failed to be templated', - 'EYQCFG', - `${error}`, - ); + if (error instanceof SemanticReleaseError) { + throw error; } - })(); - try { - await $`${yq} --expression ${rendered} ${location}`; - } catch (error) { throw new SemanticReleaseError( 'Running `yq` failed', 'EYQ', @@ -140,23 +56,23 @@ export async function verifyConditions(pluginConfig, context) { ); } - logger.success('Validated `%s`', location); - })()); + logger.success('Validated `%s`', await asset.filepath.render(ctx)); + }); await Promise.all(checks); logger.success('Validated `assets` configuration'); } export async function prepare(pluginConfig, context) { - const {assets, yq = YQ} = pluginConfig; - const {cwd, logger} = context; + const {assets: value, yq = await find()} = pluginConfig; + const {logger, ...ctx} = context; + + const assets = new Assets(value); - const updates = assets.map(({filepath, expression}) => (async () => { - const location = path.join(cwd, filepath); - const rendered = template(expression)(context); - await $`${yq} --inplace --expression ${rendered} ${location}`; - logger.success('Wrote `%s`', location); - })()); + const updates = assets.map(async asset => { + await $`${yq} --inplace ${await asset.render(ctx)}`; + logger.success('Wrote `%s`', await asset.filepath.render(ctx)); + }); await Promise.all(updates); } diff --git a/test/argument.test.mjs b/test/argument.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9487c94586e6e799b2f92eb5d8a52c94135ed538 --- /dev/null +++ b/test/argument.test.mjs @@ -0,0 +1,41 @@ +import test from 'ava'; +import Argument from '../argument.mjs'; + +test('`Argument` cannot be constructed with zero arguments', t => { + t.throws(() => new Argument()); +}); + +test('`Argument` can be constructed with one argument', t => { + const argument = new Argument('one'); + t.is(argument.argument, undefined); + t.is(argument.value, 'one'); +}); + +test('`Argument` can be constructed with two arguments', t => { + const argument = new Argument('one', 'two'); + t.is(argument.argument, 'one'); + t.is(argument.value, 'two'); +}); + +test('`Argument` cannot be constructed with three arguments', t => { + t.throws(() => new Argument('one', 'two', 'three')); +}); + +test('`Argument` can be converted to a primitive', t => { + const argument = new Argument('one'); + t.is(`${argument}`, 'one'); +}); + +test('`Argument` can be rendered with one argument', async t => { + // eslint-disable-next-line no-template-curly-in-string + const argument = new Argument('${one}'); + const rendered = await argument.render({one: 1}); + t.is(rendered, '1'); +}); + +test('`Argument` can be rendered with two arguments', async t => { + // eslint-disable-next-line no-template-curly-in-string + const argument = new Argument('--yes', '${one}'); + const rendered = await argument.render({one: 1}); + t.deepEqual(rendered, ['--yes', '1']); +}); diff --git a/test/asset.test.mjs b/test/asset.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..58162efeb75d497bf8dc4d0effcd78d2ce402860 --- /dev/null +++ b/test/asset.test.mjs @@ -0,0 +1,10 @@ +import test from 'ava'; +import Asset from '../asset.mjs'; + +test('`Asset` cannot be constructed with a string', t => { + t.throws(() => new Asset('invalid')); +}); + +test('`Asset` cannot be constructed with an array', t => { + t.throws(() => new Asset([])); +}); diff --git a/test/file-path.test.mjs b/test/file-path.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bde922d73f4a22d2b0ecc83c65461ab0c117455e --- /dev/null +++ b/test/file-path.test.mjs @@ -0,0 +1,17 @@ +import test from 'ava'; +import FilePath from '../file-path.mjs'; + +test('`FilePath` cannot be constructed with a object', t => { + t.throws(() => new FilePath({})); +}); + +test('`FilePath` throws with an invalid templated filepath', async t => { + // eslint-disable-next-line no-template-curly-in-string + const argument = new FilePath('${one}'); + await t.throwsAsync(async () => argument.render({})); +}); + +test('`FilePath` throws with an invalid filepath', async t => { + const argument = new FilePath('this-does-not-exist'); + await t.throwsAsync(async () => argument.render({})); +}); diff --git a/test/front-matter.test.mjs b/test/front-matter.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bd0408f1c100a0f71127702932c5332b1a8847e8 --- /dev/null +++ b/test/front-matter.test.mjs @@ -0,0 +1,10 @@ +import test from 'ava'; +import FrontMatter from '../front-matter.mjs'; + +test('`FrontMatter` cannot be constructed with an object', t => { + t.throws(() => new FrontMatter({})); +}); + +test('`FrontMatter` cannot be constructed with an invalid choice', t => { + t.throws(() => new FrontMatter('what')); +}); diff --git a/test/integration.test.mjs b/test/integration.test.mjs index 801f25644797b93c4c381afcbc07b1d2728642bc..0152cd0af11338dd013c15db53e4cccd75d97536 100644 --- a/test/integration.test.mjs +++ b/test/integration.test.mjs @@ -79,8 +79,6 @@ const success = test.macro(async (t, before, after) => { await t.notThrowsAsync( t.context.m.verifyConditions(t.context.cfg, t.context.ctx), ); - const data = await readFile(t.context.filepath, {encoding: 'utf8'}); - t.is(data, 'version: "1.0.0"\n'); if (after) { await after(t); @@ -112,4 +110,17 @@ test('Throws `EYQ` error when running `yq` failed', failure, async t => { t.context.cfg.assets[0].expression = '.v = nope'; }); -test('Verify and prepare assets', success); +test('Verify and prepare assets', success, undefined, async t => { + const data = await readFile(t.context.filepath, {encoding: 'utf8'}); + t.is(data, 'version: "1.0.0"\n'); +}); + +test('Verify and prepare assets with front matter', success, async t => { + t.context.cfg.assets[0].frontMatter = 'process'; + + const data = await readFile(t.context.filepath, {encoding: 'utf8'}); + await writeFile(t.context.filepath, `version: "0.0.0"\n---\n${data}`); +}, async t => { + const data = await readFile(t.context.filepath, {encoding: 'utf8'}); + t.is(data, 'version: "1.0.0"\n---\nversion: "0.0.0"'); +}); diff --git a/test/yq.test.mjs b/test/yq.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7d7fad46d2ed1b706b40dae1bd1c0a250fa25dc0 --- /dev/null +++ b/test/yq.test.mjs @@ -0,0 +1,7 @@ +import test from 'ava'; +import {bins} from '../yq.mjs'; + +test('`yq.bins` can find some binaries', async t => { + const binaries = await bins(); + t.not(binaries.length, 0); +}); diff --git a/yq.mjs b/yq.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1390acb080e06164d5d515da132d8b7dc3cee3ef --- /dev/null +++ b/yq.mjs @@ -0,0 +1,55 @@ +import {stat, readdir, access, constants} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import os from 'node:os'; +import path from 'node:path'; +import which from 'which'; + +const BIN = (() => { + const file = fileURLToPath(import.meta.url); + const dirname = path.dirname(file); + const bin = path.join(dirname, 'bin'); + return bin; +})(); + +const ARCH = { + arm: 'arm', + arm64: 'arm64', + ppc64: 'ppc64', + x64: 'amd64', + s390: 's390', + s390x: 's390x', + mips: 'mips', + riscv64: 'riscv64', +}[os.arch()]; + +const PLATFORM = { + darwin: 'darwin', + linux: 'linux', + freebsd: 'freebsd', + openbsd: 'openbsd', + netbsd: 'netbsd', + win32: 'windows', +}[os.platform()]; + +export async function bins(bin = BIN) { + const possibles = await readdir(bin); + const stats = await Promise.all(possibles.map(filepath => stat(path.join(bin, filepath)))); + return possibles.filter((_, i) => { + const status = stats[i]; + // eslint-disable-next-line no-bitwise + return status.isFile() && (status.mode & constants.S_IXUSR); + }); +} + +export default async function yq(bin = BIN, platform = PLATFORM, arch = ARCH) { + const extension = platform === 'windows' ? /* c8 ignore next */ '.exe' : ''; + const basename = `yq_${platform}_${arch}${extension}`; + const filepath = path.join(bin, basename); + try { + await access(filepath, constants.X_OK); + return filepath; + /* c8 ignore next 3 */ + } catch { + return which('yq').catch(() => undefined); + } +}