Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • semantic-release/yq
1 result
Show changes
Commits on Source (11)
# [1.1.0](https://git.gitlab.arm.com/semantic-release/yq/compare/v1.0.0...v1.1.0) (2024-02-26)
### Bug Fixes
- **release:** package Windows, Darwin, Linux binaries
([4601e23](https://git.gitlab.arm.com/semantic-release/yq/commit/4601e23ece0f573e126f937b58a3f10f4e9b9844))
### Features
- add `assets.frontMatter`
([8d3247a](https://git.gitlab.arm.com/semantic-release/yq/commit/8d3247af83d53d793e3891cb5350d2073de5c193))
# 1.0.0 (2024-01-05)
### Bug Fixes
......
* @semantic-release
[JavaScript] @matthew.clarkson
*.js
*.mjs
[Documentation] @matthew.clarkson
*.md
[Licensing] @matthew.clarkson
/.reuse/dep5
/LICENSES/*
[Configuration] @matthew.clarkson
.editorconfig
[CI] @matthew.clarkson
.gitlab-ci.yml
[Release] @matthew.clarkson
/.releaserc.yaml
[Node] @matthew.clarkson
/package.json
/.npmrc
[Branding] @matthew.clarkson
/icon.svg
[Bazel] @matthew.clarkson
/.bazelrc
/.bazelrc.ci
/.bazelignore
/.bazelversion
*.bazel
*.bzl
WORKSPACE
# 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
......@@ -52,9 +53,7 @@ plugins:
expression: ".a = .b"
```
### `assets`
#### `filepath`
### `assets[*].filepath`
A relative filepath modify in-place.
......@@ -66,7 +65,7 @@ plugins:
expression: ".a = .b"
```
#### `expression`
### `assets[*].expression`
The `yq` [expression][quick-usage-guide] to execute on the provided file.
......@@ -81,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.
......@@ -88,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
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}`;
}
}
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();
}
}
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);
}
}
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}`,
);
}
}
}
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;
}
}
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);
}
}
......@@ -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",
......
{
"name": "@semantic-release/yq",
"version": "1.0.0",
"version": "1.1.0",
"description": "A `semantic release` plugin for `yq`",
"config": {
"version": "4.40.5",
"url": "https://github.com/mikefarah/yq/releases/download/v${version}",
"filter": "^yq_(?:linux)_(?:amd64|arm64)(?:\\.exe)?$"
"filter": "^yq_(?:linux|windows|darwin)_(?:amd64|arm64)(?:\\.exe)?$"
},
"exports": {
"import": "./plugin.mjs",
......@@ -15,7 +15,7 @@
"modules": "plugin.mjs",
"files": [
"plugin.js",
"plugin.mjs",
"*.mjs",
"bin/*"
],
"scripts": {
......@@ -50,7 +50,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",
......@@ -77,9 +77,12 @@
"c8": {
"100": true,
"include": [
"plugin.mjs",
"*.mjs",
"plugin.js"
],
"exclude": [
"prepublish.mjs"
],
"reporter": [
"text",
"html",
......
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);
}
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']);
});
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([]));
});
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({}));
});
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'));
});
......@@ -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"');
});
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);
});
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);
}
}