UNPKG

@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.

355 lines (338 loc) 16.7 kB
import { type } from 'arktype'; import node_path from 'node:path'; import { cwd } from 'node:process'; import { MSBuildProject } from './MSBuildProject.mjs'; import { MSBuildProjectProperties } from './MSBuildProjectProperties.mjs'; import { NugetRegistryInfo } from './NugetRegistryInfo.mjs'; const ourDefaultPubDir = node_path.join('.', 'publish'); /** * Build a prepareCmd string from .NET projects.\ * This will include a `dotnet publish` for each project's RID and TFM permutation,\ * `dotnet pack` for each project with output paths separated by NuGet Source and PackageId,\ * and `dotnet nuget sign` for each nupkg output directory. * @todo parse Solution files to publish all projects with default Publish parameters (as evaluated by MSBuild). * @param projectsToPublish An array of relative or full file paths of `.csproj` * projects -OR- an array of {@link MSBuildProject} objects. * The project paths will be passed to `dotnet publish` commands. * @param projectsToPackAndPush * Relative and/or full file paths of projects to pass to `dotnet pack`. If * string[], only the default NuGet Source will be used. If GitHub, GitLab, * etc. are also desired, pass {@link NugetRegistryInfo}[] * @param dotnetNugetSignOpts A {@link DotnetNugetSignOptions} object. The value * of the `--output` argument will be set to {@link ourDefaultPubDir} if `undefined`. * @returns A single string of CLI commands joined by ' && ' */ async function configurePrepareCmd(projectsToPublish, projectsToPackAndPush, dotnetNugetSignOpts) { const evaluatedProjects = []; // append evaluated projects for (const p of projectsToPublish.filter(p => p instanceof MSBuildProject)) { evaluatedProjects.push(p); } if (projectsToPackAndPush) { for (const p of projectsToPackAndPush.filter(p => p instanceof NugetRegistryInfo)) { evaluatedProjects.push(p.project); } } const dotnetPublishCmd = await formatDotnetPublish(projectsToPublish); const dotnetPackCmd = await formatDotnetPack(projectsToPackAndPush ?? []); const dotnetNugetSignCmd = formatDotnetNugetSign(dotnetNugetSignOpts); return [dotnetPublishCmd, dotnetPackCmd, dotnetNugetSignCmd // remove no-op commands ].filter(v => v !== undefined).join(' && '); /** * Create a string of CLI commands to run `dotnet publish` or the Publish * MSBuild target for one or more projects. * @async * @param projectsToPublish An array of one or more projects, either * pre-evaluated (see {@link MSBuildProject.Evaluate}) or as full file paths.\ * NOTE: Although `dotnet publish` allows directory or Solution file (.sln, * .slnx) paths, this function expects projects' full or relative file * paths. * @returns A Promise of a string. This string contains one or more `dotnet publish` * commands conjoined by " && ". It may also include one or more * `dotnet msbuild ${...} -t:PublishAll -p:Configuration=Release` commands. */ async function formatDotnetPublish(projectsToPublish) { /* Fun Fact: You can define a property and get the evaluated value in the same command! ```pwsh dotnet msbuild .\src\HXE.csproj -property:RuntimeIdentifiers="""place;holder""" -getProperty:RuntimeIdentifiers place;holder ``` enclosing with """ is required in pwsh to prevent the semicolon from breaking the string. */ if (!Array.isArray(projectsToPublish) || projectsToPublish.length === 0) { throw new Error(`Type of projectsToPublish (${typeof projectsToPublish}) is not allowed. Expected a string[] or MSBuildProject[] where length > 0.`); } // each may have TargetFramework OR TargetFrameworks (plural) const evaluatedPublishProjects = await Promise.all(projectsToPublish.map(async proj => { if (proj instanceof MSBuildProject) return proj; // filter for projects whose full paths match the full path of the given string const filteredProjects = evaluatedProjects.filter(p => p.Properties.MSBuildProjectFullPath === MSBuildProjectProperties.GetFullPath(proj)); // if no pre-existing MSBuildProject found, // evaluate a new one and push it if (filteredProjects.length === 0) { const _proj = await MSBuildProject.Evaluate({ FullName: proj, GetProperty: MSBuildProject.MatrixProperties, GetItem: [], GetTargetResult: [], Property: {}, Targets: ['Restore'] }); evaluatedProjects.push(_proj); return _proj; } /** * Finds and returns the subjectively "best" project in {@link filteredProjects} * @returns the subjective "best" project in {@link filteredProjects} */ function getBest() { let best; if (filteredProjects.length > 0 && (best = filteredProjects[0]) instanceof MSBuildProject) return best; throw new Error('No MSBuildProjects could be found!'); } /* todo: improve filtering to select "optimal" instance. Which properties are most-needed? For now, we just pray the project has a well-defined publish flow e.g. @halospv3/hce.shared-config/dotnet/PublishAll.targets */ return getBest(); })); /** * Returns an array of one or more `dotnet` arguments. * @param proj An {@link MSBuildProject} to be published for one or more * runtime-framework combinations. * @returns If {@link proj} imports {@link ../../dotnet/PublishAll.targets}... * ``` * [`${proj.Properties.MSBuildProjectFullPath} -t:PublishAll -p:Configuration=Release`] * ``` * Else, an array of `dotnet publish` arguments permutations e.g. * ``` * [ * 'myProj.csproj --runtime win7-x86 --framework net6.0', * 'myProj.csproj --runtime win7-x64 --framework net6.0' * ] * ``` * @example * const publishCmdArray = []; * const permutations = getPublishArgsPermutations(msbuildProject); * for (const permutation of permutations) { * if (permutation[0] === 'PublishAll') { * // 'dotnet msbuild full/path/to/myProj.csproj t:PublishAll' * publishCmdArray.push(`dotnet msbuild ${permutation[1]}`) * } * else { * publishCmdArray.push(`dotnet publish ${permutation}`) * } * } * // return array as success-chained CLI commands. * return publishCmdArray.join(' && '); */ function getPublishArgsPermutations(proj) { /** * If the project imports PublishAll to publish for each TFM-RID * permutation, return the appropriate command line. */ if (proj.Targets.includes('PublishAll')) return [`"${proj.Properties.MSBuildProjectFullPath}" -t:PublishAll -p:Configuration=Release`]; // #region formatFrameworksAndRuntimes const tfmRidPermutations = []; // forEach, run dotnet [proj.Properties.MSBuildProjectFullPath,...v] const RIDs = proj.Properties.RuntimeIdentifiers.split(';').filter(v => v !== ''); const TFMs = proj.Properties.TargetFrameworks.split(';').filter(v => v !== ''); if (TFMs.length === 0 && RIDs.length === 0) return [`"${proj.Properties.MSBuildProjectFullPath}"`]; if (RIDs.length > 0) { if (TFMs.length > 0) { for (const RID of RIDs) { for (const TFM of TFMs) { tfmRidPermutations.push(`--runtime ${RID} --framework ${TFM}`); } } } else { // assume singular TFM. No need to specify it. for (const RID of RIDs) { tfmRidPermutations.push(`--runtime ${RID}`); } } } else if (TFMs.length > 0) { for (const TFM of TFMs) { tfmRidPermutations.push(`--framework ${TFM}`); } } /** prepend each set of args with the project's path */ return tfmRidPermutations.map(permArgs => `"${proj.Properties.MSBuildProjectFullPath}" ${permArgs}`); // #endregion formatFrameworksAndRuntimes } const publishCmds = []; /** convert {@link evaluatedPublishProjects} to sets of space-separated CLI args. */ const argsSets = evaluatedPublishProjects.map(proj => getPublishArgsPermutations(proj)); for (const args of argsSets) { if (typeof args === 'string') throw new Error(`\`args\` should not be a string!`); for (const permutation of args) { if (typeof permutation === 'string' && permutation.length === 1) throw new Error('Something has gone terribly wrong. A `dotnet publish` argument set was split to single characters!'); if (/".+" -t:PublishAll -p:Configuration=Release/.test(permutation)) publishCmds.push(`dotnet msbuild ${permutation}`);else publishCmds.push(`dotnet publish ${permutation}`); } } // For each argSet, create a new exec command. Then, join all commands with ' && ' so they are executed serially, synchronously. // e.g. `dotnet publish project.csproj --runtime win7-x86 --framework net6.0 && dotnet publish project.csproj --runtime win-x64 --framework net8.0 return publishCmds.join(' && '); } /** * @param projectsToPackAndPush a string[] or {@link NugetRegistryInfo}[]. * If a string[], the string must be the platform-dependent (not file://), * full path(s) to one or more projects with the .NET "Pack" MSBuild target. * See {@link https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-pack} * for command line usage. * @returns one or more command line strings joined with ' && '. * Each command line comprises the `dotnet pack` command, a project file path, * and a hardcoded output path (`--output ${cwd()}/publish`) */ async function formatDotnetPack(projectsToPackAndPush) { if (projectsToPackAndPush.length === 0) return undefined; return await Promise.all(projectsToPackAndPush.map(async proj => { if (proj instanceof NugetRegistryInfo) return proj; const msbpArr = await Promise.all(await MSBuildProject.PackableProjectsToMSBuildProjects([proj])); if (msbpArr.length === 0 || msbpArr[0] === undefined) { throw new Error('This should be impossible!'); } const msbp = msbpArr[0]; evaluatedProjects.push(msbp); return new NugetRegistryInfo({ project: msbp }); })).then(nriArray => { return nriArray.map(nri => nri.GetPackCommand(NugetRegistryInfo.PackPackagesOptionsType.from({}))).join(' && '); }); } } /** * Prepare the CLI command to push NuGet packages. This should added to the `publishCmd` option of `@semantic-release/exec` * * Ensure your verifyConditionsCmd is set to prevent releases failing due to bad tokens or packages! * See {@link NugetRegistryInfo#PackDummyPackage}, {@link NugetRegistryInfo#GetPushDummyCommand} * @param registryInfos an array of {@link NugetRegistryInfo} (or derived classes) instances. * @param packageOutputPath Default: `${cwd()}/publish`.\ * The directory at which dotnet outputs the given projects' packages. Passed to * `dotnet pack` via the `--output` argument. * @returns a string of `dotnet pack` and `dotnet push` commands, joined by ' && '. */ function configureDotnetNugetPush(registryInfos, // Explicit type required by JSR // eslint-disable-next-line @typescript-eslint/no-inferrable-types packageOutputPath = `${cwd()}/publish`) { if (registryInfos.some(registry => registry.source.trim() === '')) throw new Error('The URL for one of the provided NuGet registries was empty or whitespace.'); const packCmds = registryInfos.map(nri => nri.GetPackCommand({ output: packageOutputPath }, true, true)); const pushCmds = registryInfos.map(nri => nri.GetPushCommand({ root: packageOutputPath }, true, true)); return [...packCmds, ...pushCmds].join(' && '); } /** * You should try {@link ../../dotnet/SignAfterPack.targets}!. * @param opts A {@link DotnetNugetSignOptions} object to be deconstructed and * passed to `dotnet nuget sign` as args. * @returns `dotnet nuget sign {...}` */ function formatDotnetNugetSign(opts) { if (opts === undefined) return undefined; const validOpts = DotnetNugetSignOptions.from(opts); const args = ['--timestamper', validOpts.timestamper, '-o', validOpts.output ?? ourDefaultPubDir]; if (validOpts.certificatePassword) args.push('---certificate-password', validOpts.certificatePassword); if (validOpts.hashAlgorithm) args.push('--hash-algorithm', validOpts.hashAlgorithm); if (validOpts.overwrite) args.push('--overwrite'); if (validOpts.timestampHashAlgorithm) args.push('--timestamp-hash-algorithm', validOpts.timestampHashAlgorithm); if (validOpts.verbosity) args.push('-v', validOpts.verbosity); if ('certificatePath' in validOpts) args.push('--certificate-path', validOpts.certificatePath);else if ('certificateStoreName' in validOpts) { SetSubjectNameOrFingerprint(); args.push('--certificate-store-name', validOpts.certificateStoreName); } else if ('certificateStoreLocation' in validOpts) { SetSubjectNameOrFingerprint(); args.push('--certificate-store-location', validOpts.certificateStoreLocation); } else throw new Error('No code signing certificate was specified!'); return `dotnet nuget sign ${args.join(' ')} `; // eslint-disable-next-line jsdoc/require-jsdoc function SetSubjectNameOrFingerprint() { if ('certificateSubjectName' in validOpts) args.push('--certificate-subject-name', validOpts.certificateSubjectName);else if ('certificateFingerprint' in validOpts) args.push('--certificate-fingerprint', validOpts.certificateFingerprint);else throw new Error('If certificateStoreName or certificateStoreLocation is set, either certificateSubjectName or certificateFingerprint must also be set!'); } } const DotnetNugetSignOptions = type({ /** * Password for the certificate, if needed. This option can be used to specify * the password for the certificate. The command will throw an error message * if certificate is password protected but password is not provided as input. */ 'certificatePassword?': 'string', /** * Hash algorithm to be used to sign the package. Defaults to SHA256. */ 'hashAlgorithm?': 'string | "SHA256"', /** * Directory where the signed package(s) should be saved. By default the * original package is overwritten by the signed package. */ 'output?': 'string', /** * Switch to indicate if the current signature should be overwritten. By * default the command will fail if the package already has a signature. */ 'overwrite?': 'true', /** * URL to an RFC 3161 timestamping server. */ timestamper: 'string = "https://rfc3161.ai.moda/"', /** * Hash algorithm to be used to sign the package. Defaults to SHA256. */ 'timestampHashAlgorithm?': 'string | "SHA256"', /** * Set the verbosity level of the command. Allowed values are q[uiet], * m[inimal], n[ormal], d[etailed], and diag[nostic]. */ 'verbosity?': '"q"|"quiet"|"m"|"minimal"|"n"|"normal"|"d"|"detailed"|"diag"|"diagnostic"' }).and(type({ /** * File path to the certificate to be used while signing the package. */ certificatePath: 'string' }).or(type({ /** * Name of the X.509 certificate store to use to search for the * certificate. Defaults to "My", the X.509 certificate store for personal * certificates. * * This option should be used when specifying the certificate via * --certificate-subject-name or --certificate-fingerprint options. */ certificateStoreName: 'string' }).or({ /** * Name of the X.509 certificate store use to search for the * certificate. Defaults to "CurrentUser", the X.509 certificate store * used by the current user. * * This option should be used when specifying the certificate via * --certificate-subject-name or --certificate-fingerprint options. */ certificateStoreLocation: 'string' })).and(type({ /** * Subject name of the certificate used to search a local certificate * store for the certificate. The search is a case-insensitive string * comparison using the supplied value, which will find all certificates * with the subject name containing that string, regardless of other * subject values. The certificate store can be specified by * --certificate-store-name and --certificate-store-location options. */ certificateSubjectName: 'string' }).or({ /** * SHA-256, SHA-384 or SHA-512 fingerprint of the certificate used to * search a local certificate store for the certificate. The certificate * store can be specified by --certificate-store-name and * --certificate-store-location options. */ certificateFingerprint: 'string' }))); export { configureDotnetNugetPush, configurePrepareCmd }; //# sourceMappingURL=helpers.mjs.map