Skip to content

Commit

Permalink
feat: add capability to add extends configuration to release-it
Browse files Browse the repository at this point in the history
  • Loading branch information
juancarlosjr97 committed Dec 8, 2024
1 parent 09d7d1b commit 03f94dd
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 2 deletions.
19 changes: 19 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ Release-it has [plenty of options][2]. See the following tables for plugin confi
- [GitHub](./github-releases.md#configuration-options)
- [GitLab](./gitlab-releases.md#configuration-options)

### Extend Configuration

You can extend a configuration from a remote source using the `extends` option. The following formats are supported:

- `github>owner/repo`
- `github>owner/repo#tag`
- `github>owner/repo:file#tag`

For example, to extend a configuration from a GitHub repository:

```json
{
"$schema": "https://unpkg.com/release-it@17/schema/release-it.json",
"extends": "github>release-it/release-it-configuration"
}
```

Only public GitHub repositories are supported at this time.

## Setting options via CLI

Any option can also be set on the command-line, and will have highest priority. Example:
Expand Down
31 changes: 31 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ const getLocalConfig = ({ file, dir = process.cwd() }) => {
return result && _.isPlainObject(result.config) ? result.config : localConfig;
};

const fetchConfigurationFromGitHub = async pattern => {
const regex = /^github>([^/]+)\/([^#:]+)(?::([^#]+))?(?:#(.+))?$/;
const match = pattern.match(regex);

if (!match) {
throw new Error('Invalid Extended Configuration from GitHub');
}

const [, owner, repo, file = '.release-it.json', tag] = match;
const branchOrTag = tag ? `refs/tags/${tag}` : 'HEAD';
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branchOrTag}/${file}`;

const response = await fetch(url);

if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}

return response.json();
};

const getRemoteConfiguration = async configuration => {
return fetchConfigurationFromGitHub(configuration);
};

class Config {
constructor(config = {}) {
this.constructorConfig = config;
Expand Down Expand Up @@ -83,6 +108,10 @@ class Config {
);
}

mergeRemoteOptions(remoteConfiguration) {
return _.merge({}, this.options, remoteConfiguration);
}

getContext(path) {
const context = _.merge({}, this.options, this.contextOptions);
return path ? _.get(context, path) : context;
Expand Down Expand Up @@ -138,4 +167,6 @@ class Config {
}
}

export { getRemoteConfiguration };

export default Config;
11 changes: 10 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import _ from 'lodash';
import { getPlugins } from './plugin/factory.js';
import Logger from './log.js';
import Config from './config.js';
import Config, { getRemoteConfiguration } from './config.js';
import Shell from './shell.js';
import Prompt from './prompt.js';
import Spinner from './spinner.js';
Expand All @@ -14,6 +14,15 @@ const runTasks = async (opts, di) => {
Object.assign(container, di);
container.config = container.config || new Config(opts);

if ('extends' in container.config.options) {
/**
* If the configuration has an 'extends' property, fetch the remote configuration
* and merge it into the local configuration options.
*/
const remoteConfiguration = await getRemoteConfiguration(container.config.options.extends);
container.config.options = container.config.mergeRemoteOptions(remoteConfiguration);
}

const { config } = container;
const { isCI, isVerbose, verbosityLevel, isDryRun, isChangelog, isReleaseVersion } = config;

Expand Down
4 changes: 4 additions & 0 deletions schema/release-it.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"type": "string",
"description": "The JSON schema version used to validate this configuration file"
},
"extends": {
"type": "string",
"description": "URL that specifies a configuration to extend"
},
"hooks": {
"type": "object",
"additionalProperties": true,
Expand Down
116 changes: 115 additions & 1 deletion test/config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import test from 'ava';
import { isCI } from 'ci-info';
import Config from '../lib/config.js';
import sinon from 'sinon';
import Config, { getRemoteConfiguration } from '../lib/config.js';
import { readJSON } from '../lib/util.js';

const defaultConfig = readJSON(new URL('../config/release-it.json', import.meta.url));
const projectConfig = readJSON(new URL('../.release-it.json', import.meta.url));

const localConfig = { github: { release: true } };

const sandbox = sinon.createSandbox();

test.serial.afterEach(() => {
sandbox.restore();
});

test("should read this project's own configuration", t => {
const config = new Config();
t.deepEqual(config.constructorConfig, {});
Expand Down Expand Up @@ -157,3 +164,110 @@ test('should expand pre-release shortcut (snapshot)', t => {
t.is(config.options.git.tagMatch, '0.0.0-feat.[0-9]*');
t.true(config.options.git.getLatestTagFromAllRefs);
});

test.serial('should fetch extended configuration with default file and default branch', async t => {
const fetchStub = sandbox.stub(global, 'fetch');
const config = {
extends: 'github>release-it/release-it-configuration'
};

const extendedConfiguration = {
git: {
commitMessage: 'Released version ${version}'
}
};

fetchStub.resolves({
ok: true,
json: async () => extendedConfiguration
});

const response = await getRemoteConfiguration(config.extends);

t.is(
fetchStub.lastCall.args[0],
'https://raw.githubusercontent.com/release-it/release-it-configuration/HEAD/.release-it.json'
);
t.is(response, extendedConfiguration);
});

test.serial('should fetch extended configuration with default file and specific tag', async t => {
const fetchStub = sandbox.stub(global, 'fetch');
const config = {
extends: 'github>release-it/release-it-configuration#1.0.0'
};

const extendedConfiguration = {
git: {
commitMessage: 'Released version ${version}'
}
};

fetchStub.resolves({
ok: true,
json: async () => extendedConfiguration
});

const response = await getRemoteConfiguration(config.extends);

t.is(
fetchStub.lastCall.args[0],
'https://raw.githubusercontent.com/release-it/release-it-configuration/refs/tags/1.0.0/.release-it.json'
);

t.is(response, extendedConfiguration);
});

test.serial('should fetch extended configuration with custom file and specific tag', async t => {
const fetchStub = sandbox.stub(global, 'fetch');
const config = {
extends: 'github>release-it/release-it-configuration:config.json#1.0.0'
};

const extendedConfiguration = {
git: {
commitMessage: 'Released version ${version}'
}
};

fetchStub.resolves({
ok: true,
json: async () => extendedConfiguration
});

const response = await getRemoteConfiguration(config.extends);

t.is(
fetchStub.lastCall.args[0],
'https://raw.githubusercontent.com/release-it/release-it-configuration/refs/tags/1.0.0/config.json'
);

t.is(response, extendedConfiguration);
});

test.serial('should fetch extended configuration with custom file and default branch', async t => {
const fetchStub = sandbox.stub(global, 'fetch');
const config = {
extends: 'github>release-it/release-it-configuration:config.json'
};

const extendedConfiguration = {
git: {
commitMessage: 'Released version ${version}'
}
};

fetchStub.resolves({
ok: true,
json: async () => extendedConfiguration
});

const response = await getRemoteConfiguration(config.extends);

t.is(
fetchStub.lastCall.args[0],
'https://raw.githubusercontent.com/release-it/release-it-configuration/HEAD/config.json'
);

t.is(response, extendedConfiguration);
});
36 changes: 36 additions & 0 deletions test/tasks.interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,42 @@ test.serial('should run tasks without throwing errors', async t => {
t.regex(log.log.lastCall.args[0], /Done \(in [0-9]+s\.\)/);
});

test.serial('should run tasks using extended configuration', async t => {
sh.mv('.git', 'foo');

const validationExtendedConfiguration = "echo 'extended_configuration'";

const fetchStub = sandbox.stub(global, 'fetch').resolves({
ok: true,
json: async () => ({
hooks: {
'before:init': validationExtendedConfiguration
}
})
});

const config = {
$schema: 'https://unpkg.com/release-it@17/schema/release-it.json',
extends: 'github>release-it/release-it-configuration'
};

const container = getContainer(config);

const exec = sandbox.spy(container.shell, 'execFormattedCommand');

const { name, latestVersion, version } = await runTasks({}, container);

const commands = _.flatten(exec.args).filter(arg => typeof arg === 'string' && arg.startsWith('echo'));

t.true(commands.includes(validationExtendedConfiguration));

t.is(version, '0.0.1');
t.true(log.obtrusive.firstCall.args[0].includes(`release ${name} (currently at ${latestVersion})`));
t.regex(log.log.lastCall.args[0], /Done \(in [0-9]+s\.\)/);

fetchStub.restore();
});

test.serial('should not run hooks for disabled release-cycle methods', async t => {
const hooks = getHooks(['version', 'git', 'github', 'gitlab', 'npm']);

Expand Down

0 comments on commit 03f94dd

Please sign in to comment.