@halospv3/hce.shared-config
Version:
Automate commit message quality, changelogs, and CI/CD releases. Its `main` entry point is a Semantic Release config. Functions and classes are exposed for customization. An ESLint config, a Commitlint config, and addl. resources for .NET projects are als
460 lines (413 loc) • 19.9 kB
text/typescript
/**
* # Semantic-Release Config Factory (dotnet)
* A functional Semantic-Release configuration for dotnet projects
*
* extends {@link baseConfig }
*
* <-- TABLE OF CONTENTS -->
*
* EASY: {@link getConfig}
* Just provide the paths of the project file(s) and keep your API tokens ready.
* ADVANCED: {@link SemanticReleaseConfigDotnet}
* Allows for a hands-on customization if {@link getConfig} doesn't meet your needs.
* Use a copy of {@link getConfig} as the starting point of a new function and make changes from there.
* - {@link SemanticReleaseConfigDotnet#splicePlugin splicePlugin (insert/edit plugins)}
* - {@link SemanticReleaseConfigDotnet#setupDotnetCommands setupDotnetCommands}
* - {@link SemanticReleaseConfigDotnet#getTokenTestingCommands getTokenTestingCommands}
*/
import { inspect } from 'node:util';
import type { Options } from 'semantic-release';
// @ts-types="./semantic-release__exec.d.ts"
import type { Options as SRExecOptions } from '@semantic-release/exec';
import * as console from 'node:console';
import debug from './debug.ts';
import { configureDotnetNugetPush, configurePrepareCmd as configurePrepareCommand } from './dotnet/helpers.ts';
import { getEnvVarValue as getEnvironmentVariableValue } from './utils/env.ts';
import { baseConfig } from './semanticReleaseConfig.ts';
import { NugetRegistryInfo } from './dotnet/NugetRegistryInfo.ts';
import { MSBuildProject } from './dotnet/MSBuildProject.ts';
import { insertPlugin } from './insertPlugins.ts';
import { CaseInsensitiveMap } from './CaseInsensitiveMap.ts';
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 `null`, `undefined`, or `[]`; tries getting projects' semi-colon-separated
* relative paths from the `PROJECTS_TO_PACK_AND_PUSH` environment variable.
* If neither is defined, 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[] | null,
) {
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 = getEnvironmentVariableValue('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 = getEnvironmentVariableValue('PROJECTS_TO_PACK_AND_PUSH')?.split(';');
if (p && p.length > 0) {
this._projectsToPackAndPush = p;
}
else if (debug.enabled) {
debug('projectsToPackAndPush is undefined. dotnet-pack and dotnet-nuget-push setup will be skipped.');
}
}
// 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;
}
// eslint-disable-next-line jsdoc/require-param
/** @deprecated Superseded by {@link splicePlugin} */
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);
this.options.plugins.push(['@semantic-release/exec', {}]);
srExecIndex = this.options.plugins.length - 1;
}
const plugin = this.options.plugins[srExecIndex] as ['@semantic-release/exec', SRExecOptions];
const execOptions: SRExecOptions = plugin[1];
debug('Evaluating all projects with dotnet CLI...This may take a while.');
const referenceCounter = new CaseInsensitiveMap<string, number>();
const projectPromiseArrayArray = await Promise.all(
this._projectsToPackAndPush.map(async (project) => {
if (typeof project === 'string') {
debug(`Evaluating path "${project}" with "PackableProjectsToMSBuildProjects"...`);
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);
const variant = (referenceCounter.get(project) ?? 0) + packableProjects.length;
referenceCounter.set(
project,
variant,
);
debug(`Done. Path "${project}" evaluated for ${packableProjects.length.toString()} MSBuildProject instances for a total of ${variant.toString()} variants of the given path.`);
// 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 }));
}
const path = project.project.Properties.MSBuildProjectFullPath;
const variant = (referenceCounter.get(path) ?? 0) + 1;
referenceCounter.set(path, variant);
debug(`Done. Path "${path}" (variant ${variant.toString()}) is pre-evaluated. Skipping re-evaluation.`);
return [project];
}),
);
this._projectsToPackAndPush = projectPromiseArrayArray.flat();
debug('[exec:verifyConditionsCmd] Packing "Dummy" packages and generating "Push Dummy Package" commands for API token tests...');
const _pushDummyCommands = await Promise.all(
this._projectsToPackAndPush
.map(async (project) => {
const path = project.project.Properties.MSBuildProjectFullPath;
debug(`[exec:verifyConditionsCmd] Packing dummy package for "${path}"...`);
await project.PackDummyPackage({});
debug(`[exec:verifyConditionsCmd] Generating "Push Dummy Package" command for "${path}"...`);
return project.GetPushDummyCommand({});
}),
);
const verifyConditionsCommandAppendix = _pushDummyCommands.join(' && ');
execOptions.verifyConditionsCmd
= execOptions.verifyConditionsCmd && execOptions.verifyConditionsCmd.trim().length > 0
? `${execOptions.verifyConditionsCmd} && ${verifyConditionsCommandAppendix}`
: verifyConditionsCommandAppendix;
if (execOptions.verifyConditionsCmd.length === 0)
execOptions.verifyConditionsCmd = undefined;
debug('[exec:verifyConditionsCmd] Done');
const verifyReleaseCommandAppendix
= (this._projectsToPackAndPush satisfies NugetRegistryInfo[])
.map(project =>
project.GetIsNextVersionAlreadyPublishedCommand(),
).join(' && ');
execOptions.verifyReleaseCmd
= execOptions.verifyReleaseCmd && execOptions.verifyReleaseCmd.trim().length > 0
? `${execOptions.verifyReleaseCmd} && ${verifyReleaseCommandAppendix}`
: verifyConditionsCommandAppendix;
if (execOptions.verifyReleaseCmd.length === 0)
execOptions.verifyReleaseCmd = undefined;
debug('[exec:verifyReleaseCmd] Done');
const prepareCommandAppendix = await configurePrepareCommand(
this._projectsToPublish,
this._projectsToPackAndPush,
);
// 'ZipPublishDir' zips each publish folder to ./publish/*.zip
execOptions.prepareCmd
= execOptions.prepareCmd && execOptions.prepareCmd.trim().length > 0
? `${execOptions.prepareCmd} && ${prepareCommandAppendix}`
: prepareCommandAppendix;
if (execOptions.prepareCmd.length === 0)
execOptions.prepareCmd = undefined;
debug('[exec:prepareCmd] Done');
if (this._projectsToPackAndPush.length > 0) {
const publishCommandAppendix: string = configureDotnetNugetPush(
this._projectsToPackAndPush,
);
execOptions.publishCmd
= execOptions.publishCmd && execOptions.publishCmd.trim().length > 0
? `${execOptions.publishCmd} && ${publishCommandAppendix}`
: publishCommandAppendix;
if (execOptions.publishCmd.length === 0)
execOptions.publishCmd = undefined;
}
debug('[exec:publishCmd] Done');
}
/**
* Insert a plugin into the plugins array.
* @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((a, b) => a - b)
.find((_v, index, object) => index === object.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((a, b) => a - b);
for (const index of indicesOfBefore) {
if (index > indexOfLastPreceding)
continue;
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> {
let projects;
if (this._projectsToPackAndPush.every(nri => nri instanceof NugetRegistryInfo)) {
debug('[SemanticReleaseConfigDotnet#getTokenTestingCommands] All projects already evaluated.');
projects = this._projectsToPackAndPush.map(nri => nri.project);
}
else {
debug(`[SemanticReleaseConfigDotnet#getTokenTestingCommands] Evaluating up to ${this._projectsToPackAndPush.length.toString()} projects...`);
projects = await Promise.all(await MSBuildProject.PackableProjectsToMSBuildProjects(this._projectsToPackAndPush));
}
/** if a project is not in {@link EvaluatedProjects}, add it */
for (const project of projects) {
if (!this.EvaluatedProjects.includes(project))
this.EvaluatedProjects.push(project);
}
const regInfos = projects.map(
project => new NugetRegistryInfo({ project }),
);
const nupkgPaths = await Promise.all(
regInfos.map(nri =>
// eslint-disable-next-line unicorn/prefer-await
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(';');
}
debug(`${projectsToPublish.length.toString()} projects found to dotnet-publish.`);
if (!projectsToPackAndPush) {
debug('projectsToPackAndPush is empty. Checking PROJECTS_TO_PACK_AND_PUSH...');
const _ = getEnvironmentVariableValue('PROJECTS_TO_PACK_AND_PUSH');
if (_ === undefined)
debug('projectsToPackAndPush and PROJECTS_TO_PACK_AND_PUSH are empty and/or undefined.');
else projectsToPackAndPush = _.split(';');
}
debug(`${(projectsToPackAndPush?.length ?? 0).toString()} projects found to dotnet-pack and dotnet-nuget-push.`);
if (errors.length > 0) {
throw new AggregateError(
errors,
'getConfig cannot continue. One or more errors occurred.',
);
}
debug(`Instantiating ${SemanticReleaseConfigDotnet.name}...`);
const config = new SemanticReleaseConfigDotnet(
projectsToPublish,
projectsToPackAndPush,
);
debug('Setting up Dotnet commands...');
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
*/