@halospv3/hce.shared-config
Version:
Automate commit message quality, changelogs, and CI/CD releases. Exports a semantic-release shareable configuration deserialized from this package's '.releaserc.yml'. Shared resources for .NET projects are also distributed with this package.
420 lines (378 loc) • 16.8 kB
text/typescript
/**
* # Semantic-Release Config Factory (dotnet)
* A functional Semantic-Release configuration for dotnet projects
*
* extends {@link baseConfig }
*
* <-- TABLE OF CONTENTS -->
* - configureDotnetRelease
* - Insert-Edit Plugins
* - Append Plugins
*
*/
import { inspect } from 'node:util';
import type { Options } from 'semantic-release';
import type { Options as SRExecOptions } from '@semantic-release/exec';
import debug from './debug.js';
import { configureDotnetNugetPush, configurePrepareCmd } from './dotnet/helpers.js';
import { getEnvVarValue } from './utils/env.js';
import { baseConfig } from './semanticReleaseConfig.js';
import { NugetRegistryInfo } from './dotnet/NugetRegistryInfo.js';
import { MSBuildProject } from './dotnet/MSBuildProject.js';
import { insertPlugin } from './insertPlugins.js';
type UnArray<T> = T extends (infer U)[] ? U : T;
interface SRConfigDotnetOptions extends Omit<typeof baseConfig, 'plugins'> {
plugins: (UnArray<typeof baseConfig.plugins> | [string, unknown])[];
}
/**
*/
export class SemanticReleaseConfigDotnet {
private readonly options: SRConfigDotnetOptions;
private readonly _projectsToPublish: string[] | MSBuildProject[];
private _projectsToPackAndPush: string[] | NugetRegistryInfo[];
private readonly _evaluatedProjects: MSBuildProject[];
/**
* Creates an instance of SemanticReleaseConfigDotnet.
* Configures {@link baseConfig} with `@semantic-release/exec` to `dotnet` publish, pack, and push.
*
* Note: To sign packages, create a Target in the corresponding project(s) e.g.
* ```xml
* <Target Name="SignNupkgs" AfterTargets="Pack">
* <Exec Command="dotnet nuget sign $(PackageOutputPath) [remaining args]" ConsoleToMsBuild="true" />
* </Target>
* ```
* Alternatively, splice your signing commands into the publishCmd string,
* inserting them before `dotnet nuget push`.
* If you sign different signatures depending on the NuGet registry,
* splice your signing command (with "overwrite signature" enabled, if
* desired) before the corresponding registry's `dotnet nuget push` command.
* @param projectsToPublish An array of dotnet projects' relative paths. If
* empty or unspecified, tries getting projects' semi-colon-separated relative
* paths from the `PROJECTS_TO_PUBLISH` environment variable. If configured as
* recommended, the projects' publish outputs will be zipped to '$PWD/publish'
* for use in the `publish` semantic-release step (typically, GitHub release).
* @param projectsToPackAndPush An array of dotnet projects' relative paths.
* If empty or unspecified, tries getting projects' semi-colon-separated
* relative paths from the `PROJECTS_TO_PACK_AND_PUSH` environment variable.
* Otherwise, no packages will be packed and pushed.
* If configured as recommended, `dotnet pack` will output the nupkg/snupkg
* files to `$PWD/publish` where they will be globbed by `dotnet nuget push`.
*/
constructor(
projectsToPublish: string[] | MSBuildProject[],
projectsToPackAndPush: string[] | NugetRegistryInfo[],
) {
this.options = baseConfig;
/* normalize PluginSpecs to tuples */
this.options.plugins = this.options.plugins.map(pluginSpec => typeof pluginSpec === 'string'
? [pluginSpec, {}]
: pluginSpec,
);
this._projectsToPublish = projectsToPublish;
if (this._projectsToPublish.length === 0) {
const p = getEnvVarValue('PROJECTS_TO_PUBLISH')?.split(';');
if (p && p.length > 0) {
this._projectsToPublish = p;
}
else if (debug.enabled) {
debug(new Error('At least one project must be published. `projectsToPackAndPush` is empty and environment variable `PROJECTS_TO_PUBLISH` is undefined or empty.'));
}
}
this._projectsToPackAndPush = projectsToPackAndPush;
if (this._projectsToPackAndPush.length === 0) {
const p = getEnvVarValue('PROJECTS_TO_PACK_AND_PUSH')?.split(';');
if (p && p.length > 0) {
this._projectsToPackAndPush = p;
}
else if (debug.enabled) {
debug(new Error('projectsToPackAndPush.length must be > 0 or PROJECTS_TO_PACK_AND_PUSH must be defined and contain at least one path.'));
}
}
// may be zero-length array
this._evaluatedProjects = [
...this._projectsToPublish.filter(v => v instanceof MSBuildProject),
...this._projectsToPackAndPush
.filter(v => v instanceof NugetRegistryInfo)
.map(v => v.project),
];
}
get ProjectsToPublish(): string[] | MSBuildProject[] {
return this._projectsToPublish;
}
get ProjectsToPackAndPush(): string[] | NugetRegistryInfo[] {
return this._projectsToPackAndPush;
}
get EvaluatedProjects(): MSBuildProject[] {
return this._evaluatedProjects;
}
insertPlugin(
afterPluginsIDs: string[],
insertPluginIDs: string[],
beforePluginsIDs: string[],
): void {
this.options.plugins = insertPlugin(this.options.plugins, afterPluginsIDs, insertPluginIDs, beforePluginsIDs);
}
/**
* generate dotnet commands for \@semantic-release/exec, appending commands with ' && ' when necessary.
*
* Note: All strings in {@link this.ProjectsToPackAndPush} will be converted to basic {@link NugetRegistryInfo} instances with default values.
* If you need specific NRI settings or you need to push to GitLab-like or GitHub-like registries, instantiate them instead of passing their paths.
* @todo change to builder method? e.g. static async SetupDotnetCommands(this: SemanticReleaseConfigDotnet): Promise<SemanticReleaseConfigDotnet>
* @todo Add options param to allow users to enable pushing to GitLab, GitHub, NuGet.org with default settings -OR- with entirely custom settings.
* @see https://github.com/semantic-release/exec#usage
*/
async setupDotnetCommands(): Promise<void> {
let srExecIndex = this.options.plugins.findIndex(
v => v[0] === '@semantic-release/exec',
);
if (srExecIndex === -1) {
const message = `\
Unable to find\`['@semantic-release/exec', unknown]\` in plugins array!
Appending it to the end of the array...This may cause an unexpected order of operations!`;
console.warn(message);
srExecIndex = this.options.plugins.push(['@semantic-release/exec', {}]) - 1;
}
const execOptions = this.options.plugins[srExecIndex] as SRExecOptions;
// ensure all packable projects are evaluated
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
this._projectsToPackAndPush = await Promise.all(
this._projectsToPackAndPush.map(async (project) => {
if (typeof project === 'string') {
const packableProjects = await Promise.all(
await MSBuildProject.PackableProjectsToMSBuildProjects(
[project],
),
);
if (packableProjects.length === 0)
throw new Error('No MSBuildProject instances were returned!');
this._evaluatedProjects.push(...packableProjects);
// if the user doesn't want a defaulted NRI, they should pass their own NRI (or derived) instance.
return packableProjects.map(project => new NugetRegistryInfo({ project }));
}
else return [project];
}),
).then(p => p.flat()) as NugetRegistryInfo[];
// todo: double-check token-testing commands. Are they formatted prepended correctly?
const verifyConditionsCmdAppendix = await Promise.all(
this._projectsToPackAndPush
.map(async project =>
await project.PackDummyPackage({})
.then(() =>
project.GetPushDummyCommand({}),
),
),
).then(cmds =>
cmds.join(' && '),
);
execOptions.verifyConditionsCmd
= execOptions.verifyConditionsCmd && execOptions.verifyConditionsCmd.trim().length > 0
? `${execOptions.verifyConditionsCmd} && ${verifyConditionsCmdAppendix}`
: verifyConditionsCmdAppendix;
const verifyReleaseCmdAppendix
= this.ProjectsToPackAndPush
.filter(project =>
typeof project !== 'string',
).map(project =>
project.GetIsNextVersionAlreadyPublishedCommand(),
).join(' && ');
execOptions.verifyReleaseCmd
= execOptions.verifyReleaseCmd && execOptions.verifyReleaseCmd.trim().length > 0
? `${execOptions.verifyReleaseCmd} && ${verifyReleaseCmdAppendix}`
: verifyConditionsCmdAppendix;
const prepareCmdAppendix = await configurePrepareCmd(
this._projectsToPublish,
this._projectsToPackAndPush,
);
// 'ZipPublishDir' zips each publish folder to ./publish/*.zip
execOptions.prepareCmd
= execOptions.prepareCmd && execOptions.prepareCmd.trim().length > 0
? `${execOptions.prepareCmd} && ${prepareCmdAppendix}`
: prepareCmdAppendix;
// FINISHED execOptions.prepareCmd
// STARTING execOptions.publishCmd
if (this._projectsToPackAndPush.length > 0) {
const publishCmdAppendix: string = configureDotnetNugetPush(
this._projectsToPackAndPush,
);
execOptions.publishCmd
= execOptions.publishCmd && execOptions.publishCmd.trim().length > 0
? `${execOptions.publishCmd} && ${publishCmdAppendix}`
: publishCmdAppendix;
}
// FINISHED execOptions.publishCmd
}
/**
* Insert a plugin into the plugins array.
* @deprecated EXPERIMENTAL: needs thorough tests implemented before use in production!
* @param insertAfterPluginIDs Plugins which should appear BEFORE
* {@link insertPluginIDs}.
* @param insertPluginIDs The plugin(s) to insert into the plugins array.
* @param insertBeforePluginsIDs plugins which should appear AFTER the
* inserted plugin(s).
*/
splicePlugin(
insertAfterPluginIDs: string[],
insertPluginIDs: string[],
insertBeforePluginsIDs: string[],
): void {
const errors: Error[] = [];
const pluginIDs = this.options.plugins.map(v =>
typeof v === 'string' ? v : v[0],
) as (typeof this.options.plugins[number])[][0];
// if any beforePluginIDs are ordered before the last afterPlugin, throw. Impossible to sort.
const indexOfLastPreceding: number | undefined = insertAfterPluginIDs
.filter(v => pluginIDs.includes(v))
.map(v => pluginIDs.indexOf(v))
.sort()
.find((_v, i, obj) => i === obj.length - 1);
if (!indexOfLastPreceding)
throw new ReferenceError(
'An attempt to get the last element of indexOfLastAfter returned undefined.',
);
const indicesOfBefore: number[] = insertBeforePluginsIDs
.filter(v => pluginIDs.includes(v))
.map(v => pluginIDs.indexOf(v))
.sort();
for (const index of indicesOfBefore) {
if (index <= indexOfLastPreceding) {
const formattedInsertIds: string
= '[' + insertPluginIDs.map(v => `"${v}"`).join(', ') + ']';
const formattedAfterIds: string
= '[' + insertAfterPluginIDs.map(v => `"${v}"`).join(', ') + ']';
const formattedBeforeIds: string
= '[' + insertBeforePluginsIDs.map(v => `"${v}"`).join(', ') + ']';
errors.push(
new Error(
`insertPlugin was instructed to insert ${formattedInsertIds} after ${formattedAfterIds} and before ${formattedBeforeIds}, `
+ `but ${JSON.stringify(pluginIDs[indexOfLastPreceding])} is ordered after ${JSON.stringify(pluginIDs[index])}!`,
),
);
}
}
if (errors.length > 0)
throw new AggregateError(errors, 'One or more errors occurred while splicing plugin-option tuples into the Semantic Release config!');
this.options.plugins.splice(
indexOfLastPreceding + 1,
0,
...insertPluginIDs.map(v => [v, {}] satisfies [string, unknown]),
);
}
// todo: join result with dummy pack commands
protected async getTokenTestingCommands(): Promise<string> {
const promiseProjects = this.ProjectsToPackAndPush.every(nri => nri instanceof NugetRegistryInfo)
? this.ProjectsToPackAndPush.map(nri => nri.project)
: await Promise.all(await MSBuildProject.PackableProjectsToMSBuildProjects(this.ProjectsToPackAndPush));
/** if a project is not in {@link EvaluatedProjects}, add it */
for (const project of promiseProjects) {
if (!this.EvaluatedProjects.includes(project))
this.EvaluatedProjects.push(project);
}
const regInfos = promiseProjects.map(
p => new NugetRegistryInfo({ project: p }),
);
const nupkgPaths = await Promise.all(
regInfos.map(async nri =>
nri.PackDummyPackage({}).then((nupkgs) => {
// this is a full file path.
const mainNupkg = nupkgs.find(nupkg =>
new RegExp(/(? 0 or PROJECTS_TO_PUBLISH must be defined and contain at least one path.',
),
);
else projectsToPublish = _.split(';');
}
if (!projectsToPackAndPush) {
const _ = getEnvVarValue('PROJECTS_TO_PACK_AND_PUSH');
if (_ === undefined)
errors.push(
new Error(
'projectsToPackAndPush.length must be > 0 or PROJECTS_TO_PACK_AND_PUSH must be defined and contain at least one path.',
),
);
else projectsToPackAndPush = _.split(';');
}
if (errors.length > 0) {
throw new Error(
[
'getConfig cannot continue. One or more errors occurred.',
...errors.map(v => v.stack),
].join('\n'),
);
}
const config = new SemanticReleaseConfigDotnet(
projectsToPublish,
projectsToPackAndPush ?? [],
);
await config.setupDotnetCommands();
const options: Options = config.toOptions();
if (debug.enabled) {
debug('modified plugins array:');
debug(inspect(options.plugins, false, Infinity));
}
return options;
}
/**
* @module semanticReleaseConfigDotnet
*/