diff --git a/Dockerfile b/Dockerfile index 63104bf..30e1acf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ LABEL repository="https://github.com/KinsonDigital/PackageMonster" LABEL homepage="https://github.com/KinsonDigital/PackageMonster" # Label as GitHub action -LABEL com.github.actions.name="Got Nuget" +LABEL com.github.actions.name="Got Package" # Relayer the .NET SDK, anew with the build output FROM mcr.microsoft.com/dotnet/sdk:7.0 diff --git a/PackageMonster/ActionInputs.cs b/PackageMonster/ActionInputs.cs index c682ea3..26a45a7 100644 --- a/PackageMonster/ActionInputs.cs +++ b/PackageMonster/ActionInputs.cs @@ -2,6 +2,8 @@ // Copyright (c) KinsonDigital. All rights reserved. // +using PackageMonster.Services; + namespace PackageMonster; /// @@ -19,7 +21,7 @@ public class ActionInputs public string PackageName { get; set; } = string.Empty; /// - /// Gets or sets the NuGet package version to check. + /// Gets or sets the package version to check. /// /// /// Version search is not case-sensitive. @@ -27,9 +29,27 @@ public class ActionInputs [Option( "version", Required = true, - HelpText = "The version of the NuGet package to check. This is not case-sensitive.")] + HelpText = "The version of the package to check. This is not case-sensitive.")] public string Version { get; set; } = string.Empty; + /// + /// Gets or sets the repository. + /// + [Option( + "source", + Required = false, + HelpText = "The source repository to check. Defaults to `nuget`.")] + public string Source { get; set; } = string.Empty; + + /// + /// Gets or sets the json path to extract the versions. + /// + [Option( + "json-path", + Required = false, + HelpText = "The json path to the versions.")] + public string VersionsJsonPath { get; set; } = string.Empty; + /// /// Gets or sets a value indicating whether or not the action will fail if the package was not found. /// @@ -37,6 +57,16 @@ public class ActionInputs "fail-when-not-found", Required = false, Default = false, - HelpText = "If true, will fail the workflow if the NuGet package of the requested version does not exist.")] + HelpText = "If true, will fail the workflow if the package of the requested version does not exist.")] public bool? FailWhenNotFound { get; set; } + + /// + /// Gets or sets a value indicating whether or not the action will fail if the package was found. + /// + [Option( + "fail-when-found", + Required = false, + Default = false, + HelpText = "If true, will fail the workflow if the package of the requested version does exist.")] + public bool? FailWhenFound { get; set; } } diff --git a/PackageMonster/Exceptions/NugetNotFoundException.cs b/PackageMonster/Exceptions/NugetNotFoundException.cs deleted file mode 100644 index c608558..0000000 --- a/PackageMonster/Exceptions/NugetNotFoundException.cs +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright (c) KinsonDigital. All rights reserved. -// - -namespace PackageMonster.Exceptions; - -/// -/// Occurs when a NuGet package is not found. -/// -public class NugetNotFoundException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public NugetNotFoundException() - : base("The NuGet package was not found.") => HResult = 60; - - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public NugetNotFoundException(string message) - : base(message) => HResult = 60; - - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - /// - /// The instance that caused the current exception. - /// - public NugetNotFoundException(string message, Exception innerException) - : base(message, innerException) => HResult = 60; -} diff --git a/PackageMonster/Exceptions/PackageFoundException.cs b/PackageMonster/Exceptions/PackageFoundException.cs new file mode 100644 index 0000000..8efdd8c --- /dev/null +++ b/PackageMonster/Exceptions/PackageFoundException.cs @@ -0,0 +1,30 @@ +namespace PackageMonster.Exceptions; + +/// +/// Occurs when a package is found. +/// +public class PackageFoundException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public PackageFoundException() + : base("The package was not found.") => HResult = 60; + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public PackageFoundException(string message) + : base(message) => HResult = 60; + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// + /// The instance that caused the current exception. + /// + public PackageFoundException(string message, Exception innerException) + : base(message, innerException) => HResult = 60; +} diff --git a/PackageMonster/Exceptions/PackageNotFoundException.cs b/PackageMonster/Exceptions/PackageNotFoundException.cs new file mode 100644 index 0000000..4478de5 --- /dev/null +++ b/PackageMonster/Exceptions/PackageNotFoundException.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) KinsonDigital. All rights reserved. +// + +namespace PackageMonster.Exceptions; + +/// +/// Occurs when a package is not found. +/// +public class PackageNotFoundException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public PackageNotFoundException() + : base("The package was not found.") => HResult = 60; + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public PackageNotFoundException(string message) + : base(message) => HResult = 60; + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// + /// The instance that caused the current exception. + /// + public PackageNotFoundException(string message, Exception innerException) + : base(message, innerException) => HResult = 60; +} diff --git a/PackageMonster/GitHubAction.cs b/PackageMonster/GitHubAction.cs index 5001e95..5328c76 100644 --- a/PackageMonster/GitHubAction.cs +++ b/PackageMonster/GitHubAction.cs @@ -11,7 +11,7 @@ namespace PackageMonster; public sealed class GitHubAction : IGitHubAction { private readonly IGitHubConsoleService gitHubConsoleService; - private readonly INugetDataService nugetDataService; + private readonly IDataService dataService; private readonly IActionOutputService actionOutputService; private bool isDisposed; @@ -19,15 +19,15 @@ public sealed class GitHubAction : IGitHubAction /// Initializes a new instance of the class. /// /// Writes to the console. - /// Provides access to NuGet data. + /// Provides access to data. /// Sets the output data of the action. public GitHubAction( IGitHubConsoleService gitHubConsoleService, - INugetDataService nugetDataService, + IDataService dataService, IActionOutputService actionOutputService) { this.gitHubConsoleService = gitHubConsoleService; - this.nugetDataService = nugetDataService; + this.dataService = dataService; this.actionOutputService = actionOutputService; } @@ -38,32 +38,49 @@ public async Task Run(ActionInputs inputs, Action onCompleted, Action try { + if (string.IsNullOrWhiteSpace(inputs.PackageName)) + { + throw new ArgumentException("Package name is empty!"); + } + + if (string.IsNullOrWhiteSpace(inputs.Version)) + { + throw new ArgumentException("Version is empty!"); + } + this.gitHubConsoleService.Write($"Searching for package '{inputs.PackageName} v{inputs.Version}' . . . "); - var versions = await this.nugetDataService.GetNugetVersions(inputs.PackageName); + var versions = await this.dataService.GetVersions(inputs.PackageName, inputs.Source, inputs.VersionsJsonPath); var versionFound = versions .Any(version => string.Equals(version, inputs.Version, StringComparison.CurrentCultureIgnoreCase)); - var searchEndMsg = versionFound ? "package found!!" : "package not found!!"; + var searchEndMsg = versionFound ? "package found!" : "package not found!"; this.gitHubConsoleService.WriteLine(searchEndMsg); this.gitHubConsoleService.BlankLine(); - this.actionOutputService.SetOutputValue("nuget-exists", versionFound.ToString().ToLower()); + this.actionOutputService.SetOutputValue("package-exists", versionFound.ToString().ToLower()); var emoji = inputs.FailWhenNotFound is false ? "✅" : string.Empty; - var foundResultMsg = $"{emoji}The NuGet package '{inputs.PackageName}'"; + var foundResultMsg = $"{emoji}The package '{inputs.PackageName}'"; foundResultMsg += $" with the version 'v{inputs.Version}' was{(versionFound ? string.Empty : " not")} found."; if (versionFound is false) { if (inputs.FailWhenNotFound is true) { - throw new NugetNotFoundException(foundResultMsg); + throw new PackageNotFoundException(foundResultMsg); + } + } + else + { + if (inputs.FailWhenFound is true) + { + throw new PackageFoundException(foundResultMsg); } } @@ -87,7 +104,7 @@ public void Dispose() return; } - this.nugetDataService.Dispose(); + this.dataService.Dispose(); this.isDisposed = true; } diff --git a/PackageMonster/Models/NugetVersionsModel.cs b/PackageMonster/Models/NugetVersionsModel.cs deleted file mode 100644 index 039cb3a..0000000 --- a/PackageMonster/Models/NugetVersionsModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright (c) KinsonDigital. All rights reserved. -// - -namespace PackageMonster.Models; - -/// -/// Holds information about a NuGet package. -/// -/// -/// This information comes from www.nuget.org -/// -public record NugetVersionsModel -{ - /// - /// Gets or sets the list of versions available for a NuGet package. - /// - public string[] Versions { get; set; } = Array.Empty(); -} diff --git a/PackageMonster/PackageMonster.csproj b/PackageMonster/PackageMonster.csproj index dba9e2f..1ac670d 100644 --- a/PackageMonster/PackageMonster.csproj +++ b/PackageMonster/PackageMonster.csproj @@ -8,7 +8,7 @@ enable - 1.0.0-preview.2 + 1.0.0-preview.3 1.0.0-preview.2 @@ -16,7 +16,7 @@ Calvin Wilkinson Kinson Digital PackageMonster - Custom GitHub action used to check if nuget.org contains a NuGet package of a particular version. + Custom GitHub action used to check if a package repository, like nuget.org, contains a package of a particular version. Copyright ©2023 Kinson Digital @@ -27,6 +27,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/PackageMonster/Program.cs b/PackageMonster/Program.cs index 0388955..7f55244 100644 --- a/PackageMonster/Program.cs +++ b/PackageMonster/Program.cs @@ -3,6 +3,7 @@ // using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; using PackageMonster.Services; namespace PackageMonster; @@ -27,9 +28,12 @@ public static async Task Main(string[] args) { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, ArgParsingService>(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); }).Build(); diff --git a/PackageMonster/Repositories/CustomPackageRepository.cs b/PackageMonster/Repositories/CustomPackageRepository.cs new file mode 100644 index 0000000..3e60f0f --- /dev/null +++ b/PackageMonster/Repositories/CustomPackageRepository.cs @@ -0,0 +1,7 @@ +namespace PackageMonster.Repositories; + +internal class CustomPackageRepository : IPackageRepository +{ + public string Url { get; set; } + public string JsonPath { get; set; } +} diff --git a/PackageMonster/Repositories/IPackageRepository.cs b/PackageMonster/Repositories/IPackageRepository.cs new file mode 100644 index 0000000..d38f059 --- /dev/null +++ b/PackageMonster/Repositories/IPackageRepository.cs @@ -0,0 +1,7 @@ +namespace PackageMonster.Repositories; + +internal interface IPackageRepository +{ + string Url { get; } + string JsonPath { get; } +} diff --git a/PackageMonster/Repositories/NpmPackageRepository.cs b/PackageMonster/Repositories/NpmPackageRepository.cs new file mode 100644 index 0000000..0d5fbb6 --- /dev/null +++ b/PackageMonster/Repositories/NpmPackageRepository.cs @@ -0,0 +1,7 @@ +namespace PackageMonster.Repositories; + +internal class NpmPackageRepository : IPackageRepository +{ + public string Url => "https://registry.npmjs.org/PACKAGE-NAME"; + public string JsonPath => "$.versions.*.version"; +} diff --git a/PackageMonster/Repositories/NugetPackageRepository.cs b/PackageMonster/Repositories/NugetPackageRepository.cs new file mode 100644 index 0000000..5fedd58 --- /dev/null +++ b/PackageMonster/Repositories/NugetPackageRepository.cs @@ -0,0 +1,12 @@ +namespace PackageMonster.Repositories; + +/* Resources: + * These links refer to the documentation for the Nuget API + * 1. Package Content: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource + * 2. Server API: https://docs.microsoft.com/en-us/nuget/api/overview + */ +internal class NugetPackageRepository : IPackageRepository +{ + public string Url => "https://api.nuget.org/v3-flatcontainer/PACKAGE-NAME/index.json"; + public string JsonPath => "$.versions[*]"; +} diff --git a/PackageMonster/Services/ActionOutputService.cs b/PackageMonster/Services/ActionOutputService.cs index 959337e..537bff8 100644 --- a/PackageMonster/Services/ActionOutputService.cs +++ b/PackageMonster/Services/ActionOutputService.cs @@ -15,19 +15,23 @@ public class ActionOutputService : IActionOutputService private const string GitHubOutput = "GITHUB_OUTPUT"; private readonly IEnvVarService envVarService; private readonly IFile file; + private readonly IGitHubConsoleService consoleService; /// /// Initializes a new instance of the class. /// /// Manages environment variables. /// Manages files. - public ActionOutputService(IEnvVarService envVarService, IFile file) + public ActionOutputService(IEnvVarService envVarService, IFile file, IGitHubConsoleService consoleService) { EnsureThat.CtorParamIsNotNull(envVarService); EnsureThat.CtorParamIsNotNull(file); + EnsureThat.CtorParamIsNotNull(consoleService); this.envVarService = envVarService; this.file = file; + this.consoleService = consoleService; + } /// @@ -40,9 +44,15 @@ public void SetOutputValue(string name, string value) var outputPath = this.envVarService.GetEnvironmentVariable(GitHubOutput); + if (string.IsNullOrEmpty(outputPath)) + { + this.consoleService.WriteLine($"WARNING: The environment variable '{GitHubOutput}' was not specified."); + return; + } + if (this.file.Exists(outputPath) is false) { - throw new FileNotFoundException("The GitHub output environment file was not found.", outputPath); + throw new FileNotFoundException("The GitHub output file was not found.", outputPath); } var outputLines = this.file.ReadAllLines(outputPath).ToList(); diff --git a/PackageMonster/Services/DataService.cs b/PackageMonster/Services/DataService.cs new file mode 100644 index 0000000..36ab31d --- /dev/null +++ b/PackageMonster/Services/DataService.cs @@ -0,0 +1,116 @@ +// +// Copyright (c) KinsonDigital. All rights reserved. +// + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Newtonsoft.Json.Linq; +using PackageMonster.Repositories; +using RestSharp; + +namespace PackageMonster.Services; + +/// +[ExcludeFromCodeCoverage] +public sealed class DataService : IDataService +{ + private readonly RestClient client; + private bool isDisposed; + + /// + /// Initializes a new instance of the class. + /// + public DataService() => this.client = new RestClient(); + + /// + /// + /// The param is not case sensitive. The API + /// requires that it is in lowercase. This is taken care of for you. + /// + /// + /// Thrown if the param is null or empty. + /// + /// + /// Thrown if any HTTP based error occurs. + /// + public async Task GetVersions(string packageName, string source, string versionsJsonPath) + { + if (string.IsNullOrEmpty(packageName)) + { + throw new ArgumentNullException(nameof(packageName), $"Must provide a package name."); + } + + if (string.IsNullOrWhiteSpace(source)) + { + source = "nuget"; + } + + IPackageRepository packageRepository; + + switch (source.ToLowerInvariant()) + { + case "nuget": + packageRepository = new NugetPackageRepository(); + break; + case "npm": + packageRepository = new NpmPackageRepository(); + break; + default: + if (!Uri.IsWellFormedUriString(source, UriKind.Absolute)) + { + throw new ArgumentException(nameof(source), $"Must provide a well-formed source URI."); + } + + if (!Uri.TryCreate(source, UriKind.Absolute, out _)) + { + throw new ArgumentException(nameof(source), $"Must provide an absolute source URI."); + } + + if (string.IsNullOrWhiteSpace(versionsJsonPath)) + { + throw new ArgumentException(nameof(versionsJsonPath), $"Must provide a json path for a custom source. Make sure the variable `PACKAGE-NAME` is in the url."); + } + + packageRepository = new CustomPackageRepository { Url = source, JsonPath = versionsJsonPath }; + break; + } + + this.client.AcceptedContentTypes = new[] { "application/json" }; + + var resolvedUrl = packageRepository.Url.Replace("PACKAGE-NAME", packageName); + var request = new RestRequest(resolvedUrl); + + var response = await this.client.ExecuteAsync(request, Method.Get); + + if (response.StatusCode == HttpStatusCode.OK) + { + if (string.IsNullOrWhiteSpace(response.Content) || response.ContentLength == 0) + { + return Array.Empty(); + } + + var json = JObject.Parse(response.Content); + return json.SelectTokens(packageRepository.JsonPath).Select(t => t.Value()).Cast().ToArray(); + } + + var exception = response.ErrorException ?? new Exception($"There was an issue getting data from '{source}'."); + + throw new HttpRequestException( + exception.Message, + inner: null, + statusCode: response.StatusCode); + } + + /// + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + this.client.Dispose(); + + this.isDisposed = true; + } +} diff --git a/PackageMonster/Services/IDataService.cs b/PackageMonster/Services/IDataService.cs new file mode 100644 index 0000000..449d79a --- /dev/null +++ b/PackageMonster/Services/IDataService.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) KinsonDigital. All rights reserved. +// + +namespace PackageMonster.Services; + +/// +/// Provides access to data from a package marketplace. +/// +public interface IDataService : IDisposable +{ + /// + /// Gets all of the versions of a package that have been published + /// using the given . + /// + /// The name of the package. + /// The source. + /// The versions json path. + /// The list of versions that exist for the package. + Task GetVersions(string packageName, string source, string versionsJsonPath); +} diff --git a/PackageMonster/Services/INugetDataService.cs b/PackageMonster/Services/INugetDataService.cs deleted file mode 100644 index a4bd15d..0000000 --- a/PackageMonster/Services/INugetDataService.cs +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright (c) KinsonDigital. All rights reserved. -// - -namespace PackageMonster.Services; - -/// -/// Provides access to data from nuget.org marketplace. -/// -public interface INugetDataService : IDisposable -{ - /// - /// Gets all of the versions of a NuGet package that have been published - /// to nuget.org using the given . - /// - /// The name of the package. - /// The list of versions that exist for the package. - Task GetNugetVersions(string packageName); -} diff --git a/PackageMonster/Services/NugetDataService.cs b/PackageMonster/Services/NugetDataService.cs deleted file mode 100644 index c7e7a78..0000000 --- a/PackageMonster/Services/NugetDataService.cs +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright (c) KinsonDigital. All rights reserved. -// - -using System.Diagnostics.CodeAnalysis; -using System.Net; -using PackageMonster.Models; -using RestSharp; - -namespace PackageMonster.Services; - -/// -[ExcludeFromCodeCoverage] -public sealed class NugetDataService : INugetDataService -{ - /* Resources: - * These links refer to the documentation for the NuGet API - * 1. Package Content: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource - * 2.Nuget Server API: https://docs.microsoft.com/en-us/nuget/api/overview - */ - private const string BaseUrl = "https://api.nuget.org"; - private readonly RestClient client; - private bool isDisposed; - - /// - /// Initializes a new instance of the class. - /// - public NugetDataService() => this.client = new RestClient(BaseUrl); - - /// - /// - /// The param is not case sensitive. The NuGet API - /// requires that it is in lowercase. This is taken care of for you. - /// - /// - /// Thrown if the param is null or empty. - /// - /// - /// Thrown if any HTTP based error occurs. - /// - public async Task GetNugetVersions(string packageName) - { - if (string.IsNullOrEmpty(packageName)) - { - throw new ArgumentNullException(nameof(packageName), $"Must provide a NuGet package name."); - } - - this.client.AcceptedContentTypes = new[] { "application/vnd.github.v3+json" }; - - const string serviceIndexId = "v3-flatcontainer"; - var fullUrl = $"{BaseUrl}/{serviceIndexId}/{packageName.ToLower()}/index.json"; - var request = new RestRequest(fullUrl); - - var response = await this.client.ExecuteAsync(request, Method.Get); - - if (response.StatusCode == HttpStatusCode.OK) - { - return response.Data is null ? Array.Empty() : response.Data.Versions.ToArray(); - } - - var exception = response.ErrorException ?? new Exception("There was an issue getting data from NuGet."); - - throw new HttpRequestException( - exception.Message, - inner: null, - statusCode: response.StatusCode); - } - - /// - public void Dispose() - { - if (this.isDisposed) - { - return; - } - - this.client.Dispose(); - - this.isDisposed = true; - } -} diff --git a/README.md b/README.md index 62b8401..aa12f07 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## **🤷🏼‍♂️ What is it? 🤷🏼‍♂️** -### This GitHub action checks whether or not a NuGet package with a particular name and version exists in the public NuGet gallery package repository [nuget.org](https://www.nuget.org). +### This GitHub action checks whether or not a package with a particular name and version exists in a public gallery package repository like [nuget.org](https://www.nuget.org).
@@ -29,7 +29,7 @@ > - [Defining outputs for jobs](https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs) > - [Setting a step action output parameter](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter) -

🪧 Example 🪧

+

🪧 NuGet Example 🪧

```yaml name: Package Monster Action Sample @@ -44,8 +44,8 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Check If Nuget Package Exists - id: nuget-exists + - name: Check If Package Exists + id: package-exists uses: KinsonDigital/PackageMonster@v1.0.0-preview.1 with: package-name: MyPackage 👈🏻 # Required input @@ -58,12 +58,53 @@ jobs: # Output name for the Package Monster GitHub action 👇🏼 # _____|_____ # | | - $nugetExists = "${{ steps.nuget-exists.outputs.nuget-exists }}"; + $packageExists = "${{ steps.package-exists.outputs.package-exists }}"; - if ($nugetExists -eq "true") { - Write-Host "The NuGet package exists!!"; + if ($packageExists -eq "true") { + Write-Host "The package exists!!"; } else { - Write-Host "The NuGet package does not exist!!"; + Write-Host "The package does not exist!!"; + } +``` + +--- + +

🪧 NPM Example 🪧

+ +```yaml +name: Package Monster Action Sample + +on: + workflow_dispatch: + +jobs: + Test_Action:em + name: Test Package Monster GitHub Action + runs-on: ubuntu-latest 👈🏼 # Required (Refer to the note above) + steps: + - uses: actions/checkout@v3 + + - name: Check If NPM Package Exists + id: package-exists + uses: KinsonDigital/PackageMonster@v1.0.0-preview.1 + with: + package-name: MyPackage 👈🏻 # Required input + version: 1.2.3 👈🏻 # Required input + source: npm + fail-when-not-found: true 👈🏻 # Optional input + + - name: Print Output Result #PowerShell Core + shell: pwsh 👈🏼 # Must be explicit with the shell to use PowerShell on Ubuntu + run: | + # Output name for the Package Monster GitHub action 👇🏼 + # _____|_____ + # | | + $packageExists = "${{ steps.package-exists.outputs.package-exists }}"; + + if ($packageExists -eq "true") { + Write-Host "The package exists!!"; + } else { + Write-Host "The package does not exist!!"; } ``` @@ -74,11 +115,14 @@ jobs: ## **➡️ Action Inputs ⬅️** -| Input Name | Description | Required | Default Value | -|---|:---------------------------------------------------------------------------|:---:|:---:| -| `package-name` | The name of the NuGet package. | yes | N/A | -| `version` | The version of the package. | yes | N/A | -| `fail-when-not-found` | Will fail the job if the NuGet package of a specific version is not found. | no | false | +| Input Name | Description | Required | Default Value | Notes | +|-----------------------|:---------------------------------------------------------------------|:--------:|:-------------:|:-------:| +| `package-name` | The name of the package. | yes | N/A | | +| `version` | The version of the package. | yes | N/A | | +| `source` | The source repository to check. | no | nuget | Valid options are: `nuget`, `npm`, or a custom url. If 'PACKAGE-NAME' is in the url, it will be replaced with the value from the `package-name` input parameter. | +| `json-path` | The json path to extract the versions. | no | N/A | Required if `source` is set to a custom url. Refer to https://jsonpath.com for syntax. | +| `fail-when-found` | Will fail the job if the package of a specific version is found. | no | false | | +| `fail-when-not-found` | Will fail the job if the package of a specific version is not found. | no | false | |
@@ -87,7 +131,7 @@ jobs: ## **⬅️ Action Output ➡️**
-The name of the output is `nuget-exists` and it returns a `boolean` of `true` or `false`. +The name of the output is `package-exists` and it returns a `boolean` of `true` or `false`. Refer to the **Example** above for how to use the output of the action. --- diff --git a/Testing/PackageMonsterTests/ActionInputTests.cs b/Testing/PackageMonsterTests/ActionInputTests.cs index 1c9d3a6..bcdff88 100644 --- a/Testing/PackageMonsterTests/ActionInputTests.cs +++ b/Testing/PackageMonsterTests/ActionInputTests.cs @@ -32,12 +32,17 @@ public void Ctor_WhenConstructed_PropsHaveCorrectDefaultValuesAndDecoratedWithAt inputs.PackageName.Should().BeEmpty(); typeof(ActionInputs).GetProperty(nameof(ActionInputs.Version)).Should().BeDecoratedWith(); inputs.GetAttrFromProp(nameof(ActionInputs.Version)) - .AssertOptionAttrProps("version", true, "The version of the NuGet package to check. This is not case-sensitive."); + .AssertOptionAttrProps("version", true, "The version of the package to check. This is not case-sensitive."); inputs.PackageName.Should().BeEmpty(); typeof(ActionInputs).GetProperty(nameof(ActionInputs.FailWhenNotFound)).Should().BeDecoratedWith(); inputs.GetAttrFromProp(nameof(ActionInputs.FailWhenNotFound)) - .AssertOptionAttrProps("fail-when-not-found", false, false, "If true, will fail the workflow if the NuGet package of the requested version does not exist."); + .AssertOptionAttrProps("fail-when-not-found", false, false, "If true, will fail the workflow if the package of the requested version does not exist."); + + inputs.PackageName.Should().BeEmpty(); + typeof(ActionInputs).GetProperty(nameof(ActionInputs.FailWhenFound)).Should().BeDecoratedWith(); + inputs.GetAttrFromProp(nameof(ActionInputs.FailWhenFound)) + .AssertOptionAttrProps("fail-when-found", false, false, "If true, will fail the workflow if the package of the requested version does exist."); } #endregion } diff --git a/Testing/PackageMonsterTests/Exceptions/NugetNotFoundExceptionTests.cs b/Testing/PackageMonsterTests/Exceptions/PackageNotFoundExceptionTests.cs similarity index 69% rename from Testing/PackageMonsterTests/Exceptions/NugetNotFoundExceptionTests.cs rename to Testing/PackageMonsterTests/Exceptions/PackageNotFoundExceptionTests.cs index d1f86c2..d3f99bd 100644 --- a/Testing/PackageMonsterTests/Exceptions/NugetNotFoundExceptionTests.cs +++ b/Testing/PackageMonsterTests/Exceptions/PackageNotFoundExceptionTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) KinsonDigital. All rights reserved. // @@ -8,19 +8,19 @@ namespace PackageMonsterTests.Exceptions; using PackageMonster.Exceptions; /// -/// Tests the class. +/// Tests the class. /// -public class NugetNotFoundExceptionTests +public class PackageNotFoundExceptionTests { #region Constructor Tests [Fact] public void Ctor_WithNoParam_CorrectlySetsExceptionMessage() { // Act - var exception = new NugetNotFoundException(); + var exception = new PackageNotFoundException(); // Assert - exception.Message.Should().Be("The NuGet package was not found."); + exception.Message.Should().Be("The package was not found."); exception.HResult.Should().Be(60); } @@ -28,7 +28,7 @@ public void Ctor_WithNoParam_CorrectlySetsExceptionMessage() public void Ctor_WhenInvokedWithSingleMessageParam_CorrectlySetsMessage() { // Act - var exception = new NugetNotFoundException("test-message"); + var exception = new PackageNotFoundException("test-message"); // Assert exception.Message.Should().Be("test-message"); @@ -42,7 +42,7 @@ public void Ctor_WhenInvokedWithMessageAndInnerException_ThrowsException() var innerException = new Exception("inner-exception"); // Act - var deviceException = new NugetNotFoundException("test-exception", innerException); + var deviceException = new PackageNotFoundException("test-exception", innerException); // Assert deviceException.InnerException.Message.Should().Be("inner-exception"); diff --git a/Testing/PackageMonsterTests/GitHubActionTests.cs b/Testing/PackageMonsterTests/GitHubActionTests.cs index 812ac0b..a0e2a28 100644 --- a/Testing/PackageMonsterTests/GitHubActionTests.cs +++ b/Testing/PackageMonsterTests/GitHubActionTests.cs @@ -14,7 +14,7 @@ namespace PackageMonsterTests; public class GitHubActionTests { private readonly Mock mockConsoleService; - private readonly Mock mockDataService; + private readonly Mock mockDataService; private readonly Mock mockActionOutputService; /// @@ -23,7 +23,7 @@ public class GitHubActionTests public GitHubActionTests() { this.mockConsoleService = new Mock(); - this.mockDataService = new Mock(); + this.mockDataService = new Mock(); this.mockActionOutputService = new Mock(); } @@ -46,7 +46,7 @@ public async void Run_WhenInvoked_ShowsWelcomeMessage() [InlineData("test-package", "4.5.6", "true")] [InlineData("TEST-PACKAGE", "4.5.6", "true")] [InlineData("test-package", "7.8.9", "false")] - public async void Run_WhenNugetPackageWithVersionExists_SetsOutputToCorrectValue( + public async void Run_WhenPackageWithVersionExists_SetsOutputToCorrectValue( string packageName, string version, string expectedOutput) @@ -55,7 +55,7 @@ public async void Run_WhenNugetPackageWithVersionExists_SetsOutputToCorrectValue var expectedSearchMsgStart = $"Searching for package '{packageName} v{version}' . . . "; var expectedSearchMsgEnd = expectedOutput == "true" ? "package found!!" : "package not found!!"; - this.mockDataService.Setup(m => m.GetNugetVersions(packageName)) + this.mockDataService.Setup(m => m.GetVersions(packageName, It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { "1.2.3", "4.5.6" }); var onCompletedInvoked = false; @@ -67,14 +67,14 @@ public async void Run_WhenNugetPackageWithVersionExists_SetsOutputToCorrectValue // Assert await act.Should().NotThrowAsync(); - var expectedResultMsg = $"✅The NuGet package '{packageName}'"; + var expectedResultMsg = $"✅The package '{packageName}'"; expectedResultMsg += $" with the version 'v{version}' was{(expectedOutput == "false" ? " not" : string.Empty)} found."; this.mockConsoleService.VerifyOnce(m => m.Write(expectedSearchMsgStart, false)); this.mockConsoleService.VerifyOnce(m => m.WriteLine(expectedSearchMsgEnd)); this.mockConsoleService.VerifyOnce(m => m.WriteLine(expectedResultMsg)); this.mockConsoleService.Verify(m => m.BlankLine(), Times.Exactly(4)); - this.mockActionOutputService.VerifyOnce(m => m.SetOutputValue("nuget-exists", expectedOutput)); + this.mockActionOutputService.VerifyOnce(m => m.SetOutputValue("package-exists", expectedOutput)); onCompletedInvoked.Should().BeTrue("the 'onCompleted()' was never invoked"); } @@ -84,7 +84,7 @@ public async void Run_WhenPackageIsNotFoundWithFailSetToTrue_ThrowsExceptionWith // Arrange var inputs = CreateInputs(failWhenNotFound: true); - this.mockDataService.Setup(m => m.GetNugetVersions(It.IsAny())) + this.mockDataService.Setup(m => m.GetVersions(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); var action = CreateAction(); @@ -94,8 +94,8 @@ public async void Run_WhenPackageIsNotFoundWithFailSetToTrue_ThrowsExceptionWith // Assert await act.Should() - .ThrowAsync() - .WithMessage($"The NuGet package '{inputs.PackageName}' with the version 'v{inputs.Version}' was not found."); + .ThrowAsync() + .WithMessage($"The package '{inputs.PackageName}' with the version 'v{inputs.Version}' was not found."); } [Theory] @@ -106,7 +106,7 @@ public async void Run_WhenPackageIsNotFoundWithFailSetToFalseOrNull_DoesNotThrow // Arrange var inputs = CreateInputs(failWhenNotFound: failWhenNotFound); - this.mockDataService.Setup(m => m.GetNugetVersions(It.IsAny())) + this.mockDataService.Setup(m => m.GetVersions(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); var action = CreateAction(); @@ -116,15 +116,15 @@ public async void Run_WhenPackageIsNotFoundWithFailSetToFalseOrNull_DoesNotThrow // Assert await act.Should() - .NotThrowAsync(); + .NotThrowAsync(); } [Fact] public async void Run_WhenAnExceptionIsThrown_InvokesOnErrorActionParam() { // Arrange - this.mockDataService.Setup(m => m.GetNugetVersions(It.IsAny())) - .Callback(_ => throw new Exception("test-exception")); + this.mockDataService.Setup(m => m.GetVersions(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _) => throw new Exception("test-exception")); Exception? exception = null; var inputs = CreateInputs(); @@ -160,11 +160,13 @@ public void Dispose_WhenInvoked_DisposesOfAction() private static ActionInputs CreateInputs( string packageName = "test-package", string version = "1.2.3", - bool? failWhenNotFound = true) => new () + bool? failWhenNotFound = true, + bool? failWhenFound = false) => new () { PackageName = packageName, Version = version, FailWhenNotFound = failWhenNotFound, + FailWhenFound = failWhenFound }; /// diff --git a/Testing/PackageMonsterTests/Models/NugetVersionsModelTests.cs b/Testing/PackageMonsterTests/Models/NugetVersionsModelTests.cs deleted file mode 100644 index 7331220..0000000 --- a/Testing/PackageMonsterTests/Models/NugetVersionsModelTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright (c) KinsonDigital. All rights reserved. -// - -// ReSharper disable UseObjectOrCollectionInitializer -namespace PackageMonsterTests.Models; - -using FluentAssertions; -using PackageMonster.Models; - -/// -/// Tests the class. -/// -public class NugetVersionsModelTests -{ - #region Prop Tests - [Fact] - public void Version_WhenSettingValue_ReturnsCorrectResult() - { - // Arrange - var model = new NugetVersionsModel(); - - // Act - model.Versions = new[] { "1.2.3", "4.5.6" }; - - // Assert - model.Versions.Should() - .HaveCount(2) - .And.Contain("1.2.3") - .And.Contain("4.5.6") - .And.HaveElementPreceding("4.5.6", "1.2.3"); - } - #endregion -} diff --git a/Testing/PackageMonsterTests/Models/PackageVersionsModelTests.cs b/Testing/PackageMonsterTests/Models/PackageVersionsModelTests.cs new file mode 100644 index 0000000..e40947c --- /dev/null +++ b/Testing/PackageMonsterTests/Models/PackageVersionsModelTests.cs @@ -0,0 +1,78 @@ +// +// Copyright (c) KinsonDigital. All rights reserved. +// + +// ReSharper disable UseObjectOrCollectionInitializer + +using Newtonsoft.Json.Linq; +using PackageMonster.Repositories; +using PackageMonster.Services; + +namespace PackageMonsterTests.Models; + +using FluentAssertions; + +/// +/// Tests the versions Json Path functionality. +/// +public class PackageVersionsModelTests +{ + #region Prop Tests + [Fact] + public void Version_WhenUsingNugetJsonPath_ReturnsCorrectResult() + { + // Arrange + var packageRepository = new NugetPackageRepository(); + var model = JObject.Parse(@" +{ + ""versions"": [ + ""1.2.3"", + ""4.5.6"" + ] +} +"); + + // Act + var actual = model.SelectTokens(packageRepository.JsonPath).Select(v => v.Value()).ToArray(); + + // Assert + actual.Should() + .HaveCount(2) + .And.Contain("1.2.3") + .And.Contain("4.5.6") + .And.HaveElementPreceding("4.5.6", "1.2.3"); + } + #endregion + + + #region Prop Tests + [Fact] + public void Version_WhenUsingNpmJsonPath_ReturnsCorrectResult() + { + // Arrange + var packageRepository = new NpmPackageRepository(); + var model = JObject.Parse(@" +{ + ""versions"": { + ""1.2.3"": { + ""version"": ""1.2.3"" + }, + ""4.5.6"": { + ""version"": ""4.5.6"" + } + } +} +"); + + // Act + var actual = model.SelectTokens(packageRepository.JsonPath).Select(v => v.Value()).ToArray(); + + // Assert + actual.Should() + .HaveCount(2) + .And.Contain("1.2.3") + .And.Contain("4.5.6") + .And.HaveElementPreceding("4.5.6", "1.2.3"); + } + #endregion +} diff --git a/Testing/PackageMonsterTests/Services/ActionOutputServiceTests.cs b/Testing/PackageMonsterTests/Services/ActionOutputServiceTests.cs index 863d3b5..cecc01d 100644 --- a/Testing/PackageMonsterTests/Services/ActionOutputServiceTests.cs +++ b/Testing/PackageMonsterTests/Services/ActionOutputServiceTests.cs @@ -15,6 +15,7 @@ public class ActionOutputServiceTests { private readonly Mock mockEnvVarService; private readonly Mock mockFile; + private readonly Mock mockConsoleService; /// /// Initializes a new instance of the class. @@ -23,6 +24,7 @@ public ActionOutputServiceTests() { this.mockEnvVarService = new Mock(); this.mockFile = new Mock(); + this.mockConsoleService = new Mock(); } #region Constructor Tests @@ -32,7 +34,7 @@ public void Ctor_WithNullEnvVarServiceParam_ThrowsException() // Arrange & Act var act = () => { - _ = new ActionOutputService(null, Mock.Of()); + _ = new ActionOutputService(null, Mock.Of(), Mock.Of()); }; // Assert @@ -47,7 +49,7 @@ public void Ctor_WithFileParam_ThrowsException() // Arrange & Act var act = () => { - _ = new ActionOutputService(Mock.Of(), null); + _ = new ActionOutputService(Mock.Of(), null, null); }; // Assert @@ -55,6 +57,21 @@ public void Ctor_WithFileParam_ThrowsException() .Throw() .WithMessage("The parameter must not be null. (Parameter 'file')"); } + + [Fact] + public void Ctor_WithConsoleServiceParam_ThrowsException() + { + // Arrange & Act + var act = () => + { + _ = new ActionOutputService(Mock.Of(), Mock.Of(), null); + }; + + // Assert + act.Should() + .Throw() + .WithMessage("The parameter must not be null. (Parameter 'consoleService')"); + } #endregion #region Method Tests @@ -75,6 +92,23 @@ public void SetOutputValue_WithNullOrEmptyOutputName_ThrowsException(string name .WithMessage("The parameter 'name' must not be null or empty."); } + [Fact] + public void SetOutputValue_WhenOutputPathNotSpecified_LogWarning() + { + // Arrange + const string outputPath = ""; + this.mockEnvVarService + .Setup(m => m.GetEnvironmentVariable(It.IsAny(), It.IsAny())) + .Returns(outputPath); + var sut = CreateSystemUnderTest(); + + // Act + sut.SetOutputValue("test-output", "test-value"); + + // Assert + this.mockConsoleService.VerifyOnce(m => m.WriteLine("WARNING: The environment variable 'GITHUB_OUTPUT' was not specified.")); + } + [Fact] public void SetOutputValue_WhenOutputPathDoesNotExist_ThrowsException() { @@ -91,18 +125,16 @@ public void SetOutputValue_WhenOutputPathDoesNotExist_ThrowsException() // Assert act.Should().Throw() - .WithMessage("The GitHub output environment file was not found."); + .WithMessage("The GitHub output file was not found."); } [Fact] public void SetOutputValue_WhenInvoked_SetsOutputValue() { // Arrange - var expected = - $""" - other-output=other-value - test-output=test-value{Environment.NewLine} - """; + var expected = @"other-output=other-value +test-output=test-value +".ReplaceLineEndings(Environment.NewLine); const string outputPath = "test-path"; var lines = new[] @@ -134,5 +166,5 @@ public void SetOutputValue_WhenInvoked_SetsOutputValue() /// /// The instance to test. private ActionOutputService CreateSystemUnderTest() => - new (this.mockEnvVarService.Object, this.mockFile.Object); + new (this.mockEnvVarService.Object, this.mockFile.Object, this.mockConsoleService.Object); } diff --git a/action.yml b/action.yml index 95a76a5..c1bd19d 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'PackageMonster' -description: 'Checks if a NuGet package exists in the nuget.org public repository.' +description: 'Checks if a package exists in the a public repository.' author: 'Calvin Wilkinson (KinsonDigital)' branding: icon: settings @@ -9,16 +9,27 @@ inputs: description: 'The name of the package. This is not case-sensitive.' required: true version: - description: 'The version of the NuGet package to check. This is not case-sensitive.' + description: 'The version of the package to check. This is not case-sensitive.' required: true + source: + description: 'The source repository to check. The string `PACKAGE-NAME` will be replaced with the value from the `package-name` input parameter. Options: [ `nuget`, `npm`, custom url ]' + required: false + default: nuget + json-path: + description: 'The json path to extract the versions. Required only if a custom source url is specified.' + required: false fail-when-not-found: - description: 'If true, will fail the workflow if the NuGet package of the requested version does not exist.' + description: 'If true, will fail the workflow if the package of the requested version does not exist.' + required: false + default: 'false' + fail-when-found: + description: 'If true, will fail the workflow if the package of the requested version does exist.' required: false - default: false + default: 'false' outputs: - nuget-exists: - description: 'True if the NuGet package exists.' + package-exists: + description: 'True if the package exists.' # These are the arguments that are passed into the console app runs: @@ -29,5 +40,11 @@ runs: - ${{ inputs.package-name }} - '--version' - ${{ inputs.version }} + - '--source' + - ${{ inputs.source }} + - '--json-path' + - ${{ inputs.json-path }} - '--fail-when-not-found' - ${{ inputs.fail-when-not-found }} + - '--fail-when-found' + - ${{ inputs.fail-when-found }}