diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index f1c5f8807..07a1c9458 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -39,3 +39,8 @@ jobs: - name: Verify landing-page-path output run: test ${{ steps.docs-build.outputs.landing-page-path }} == ${{ matrix.landing-page-path-output }} + + - name: Verify link validation + run: | + dotnet run --project src/tooling/docs-builder -- inbound-links validate-link-reference -p test-repo + diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index a144808c0..2cdce4a08 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -77,13 +77,13 @@ public BuildContext( ? ReadFileSystem.DirectoryInfo.New(source) : ReadFileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName)); - (DocumentationSourceDirectory, ConfigurationPath) = FindDocsFolderFromRoot(rootFolder); + (DocumentationSourceDirectory, ConfigurationPath) = Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder); DocumentationCheckoutDirectory = Paths.DetermineSourceDirectoryRoot(DocumentationSourceDirectory); DocumentationOutputDirectory = !string.IsNullOrWhiteSpace(output) ? WriteFileSystem.DirectoryInfo.New(output) - : WriteFileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, Path.Combine(".artifacts", "docs", "html"))); + : WriteFileSystem.DirectoryInfo.New(Path.Combine(rootFolder.FullName, Path.Combine(".artifacts", "docs", "html"))); if (ConfigurationPath.FullName != DocumentationSourceDirectory.FullName) DocumentationSourceDirectory = ConfigurationPath.Directory!; @@ -96,29 +96,4 @@ public BuildContext( }; } - private (IDirectoryInfo, IFileInfo) FindDocsFolderFromRoot(IDirectoryInfo rootPath) - { - string[] files = ["docset.yml", "_docset.yml"]; - string[] knownFolders = [rootPath.FullName, Path.Combine(rootPath.FullName, "docs")]; - var mostLikelyTargets = - from file in files - from folder in knownFolders - select Path.Combine(folder, file); - - var knownConfigPath = mostLikelyTargets.FirstOrDefault(ReadFileSystem.File.Exists); - var configurationPath = knownConfigPath is null ? null : ReadFileSystem.FileInfo.New(knownConfigPath); - if (configurationPath is not null) - return (configurationPath.Directory!, configurationPath); - - configurationPath = rootPath - .EnumerateFiles("*docset.yml", SearchOption.AllDirectories) - .FirstOrDefault() - ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); - - var docsFolder = configurationPath.Directory - ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); - - return (docsFolder, configurationPath); - } - } diff --git a/src/Elastic.Documentation.Configuration/Paths.cs b/src/Elastic.Documentation.Configuration/Paths.cs index ef93df3b5..66698dc83 100644 --- a/src/Elastic.Documentation.Configuration/Paths.cs +++ b/src/Elastic.Documentation.Configuration/Paths.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; namespace Elastic.Documentation.Configuration; @@ -50,4 +51,47 @@ private static DirectoryInfo GetApplicationFolder() return new DirectoryInfo(elasticPath); } + public static (IDirectoryInfo, IFileInfo) FindDocsFolderFromRoot(IFileSystem readFileSystem, IDirectoryInfo rootPath) + { + string[] files = ["docset.yml", "_docset.yml"]; + string[] knownFolders = [rootPath.FullName, Path.Combine(rootPath.FullName, "docs")]; + var mostLikelyTargets = + from file in files + from folder in knownFolders + select Path.Combine(folder, file); + + var knownConfigPath = mostLikelyTargets.FirstOrDefault(readFileSystem.File.Exists); + var configurationPath = knownConfigPath is null ? null : readFileSystem.FileInfo.New(knownConfigPath); + if (configurationPath is not null) + return (configurationPath.Directory!, configurationPath); + + configurationPath = rootPath + .EnumerateFiles("*docset.yml", SearchOption.AllDirectories) + .FirstOrDefault() + ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); + + var docsFolder = configurationPath.Directory ?? throw new Exception($"Can not locate docset.yml file in '{rootPath}'"); + + return (docsFolder, configurationPath); + } + + public static bool TryFindDocsFolderFromRoot( + IFileSystem readFileSystem, + IDirectoryInfo rootPath, + [NotNullWhen(true)] out IDirectoryInfo? docDirectory, + [NotNullWhen(true)] out IFileInfo? configurationPath + ) + { + docDirectory = null; + configurationPath = null; + try + { + (docDirectory, configurationPath) = FindDocsFolderFromRoot(readFileSystem, rootPath); + return true; + } + catch + { + return false; + } + } } diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index c724b7dc2..729377bb3 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -4,12 +4,13 @@ using System.IO.Abstractions; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using SoftCircuits.IniFileParser; namespace Elastic.Documentation; -public record GitCheckoutInformation +public partial record GitCheckoutInformation { public static GitCheckoutInformation Unavailable { get; } = new() { @@ -74,31 +75,33 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem using var streamReader = new StreamReader(stream); ini.Load(streamReader); - var remote = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); + var remote = BranchTrackingRemote(branch, ini); + logger?.LogInformation("Remote from branch: {GitRemote}", remote); if (string.IsNullOrEmpty(remote)) { - remote = BranchTrackingRemote(branch, ini); - logger?.LogInformation("Remote from branch: {GitRemote}", remote); - if (string.IsNullOrEmpty(remote)) - { - remote = BranchTrackingRemote("main", ini); - logger?.LogInformation("Remote from main branch: {GitRemote}", remote); - } + remote = BranchTrackingRemote("main", ini); + logger?.LogInformation("Remote from main branch: {GitRemote}", remote); + } - if (string.IsNullOrEmpty(remote)) - { - remote = BranchTrackingRemote("master", ini); - logger?.LogInformation("Remote from master branch: {GitRemote}", remote); - } + if (string.IsNullOrEmpty(remote)) + { + remote = BranchTrackingRemote("master", ini); + logger?.LogInformation("Remote from master branch: {GitRemote}", remote); + } - if (string.IsNullOrEmpty(remote)) - { - remote = "elastic/docs-builder-unknown"; - logger?.LogInformation("Remote from fallback: {GitRemote}", remote); - } - remote = remote.AsSpan().TrimEnd("git").TrimEnd('.').ToString(); + if (string.IsNullOrEmpty(remote)) + { + remote = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY"); + logger?.LogInformation("Remote from GITHUB_REPOSITORY: {GitRemote}", remote); } + if (string.IsNullOrEmpty(remote)) + { + remote = "elastic/docs-builder-unknown"; + logger?.LogInformation("Remote from fallback: {GitRemote}", remote); + } + remote = CutOffGitExtension().Replace(remote, string.Empty); + var info = new GitCheckoutInformation { Ref = gitRef, @@ -137,4 +140,7 @@ string BranchTrackingRemote(string b, IniFile c) return remote ?? string.Empty; } } + + [GeneratedRegex(@"\.git$", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex CutOffGitExtension(); } diff --git a/src/tooling/docs-assembler/Cli/InboundLinkCommands.cs b/src/tooling/docs-assembler/Cli/InboundLinkCommands.cs index 90314d50c..42857d8f9 100644 --- a/src/tooling/docs-assembler/Cli/InboundLinkCommands.cs +++ b/src/tooling/docs-assembler/Cli/InboundLinkCommands.cs @@ -17,13 +17,13 @@ namespace Documentation.Assembler.Cli; internal sealed class InboundLinkCommands(ILoggerFactory logger, ICoreService githubActionsService) { private readonly LinkIndexLinkChecker _linkIndexLinkChecker = new(logger); + private readonly ILogger _log = logger.CreateLogger(); [SuppressMessage("Usage", "CA2254:Template should be a static expression")] private void AssignOutputLogger() { - var log = logger.CreateLogger(); - ConsoleApp.Log = msg => log.LogInformation(msg); - ConsoleApp.LogError = msg => log.LogError(msg); + ConsoleApp.Log = msg => _log.LogInformation(msg); + ConsoleApp.LogError = msg => _log.LogError(msg); } /// Validate all published cross_links in all published links.json files. @@ -64,19 +64,36 @@ public async Task ValidateRepoInboundLinks(string? from = null, string? to /// Validate a locally published links.json file against all published links.json files in the registry /// /// Path to `links.json` defaults to '.artifacts/docs/html/links.json' + /// -p, Defaults to the `{pwd}` folder /// [Command("validate-link-reference")] - public async Task ValidateLocalLinkReference([Argument] string? file = null, Cancel ctx = default) + public async Task ValidateLocalLinkReference(string? file = null, string? path = null, Cancel ctx = default) { AssignOutputLogger(); file ??= ".artifacts/docs/html/links.json"; var fs = new FileSystem(); - var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var repository = GitCheckoutInformation.Create(root, new FileSystem(), logger.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName + var root = !string.IsNullOrEmpty(path) ? fs.DirectoryInfo.New(path) : fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var repository = GitCheckoutInformation.Create(root, fs, logger.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName ?? throw new Exception("Unable to determine repository name"); + var resolvedFile = fs.FileInfo.New(Path.Combine(root.FullName, file)); + + var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + if (runningOnCi && !resolvedFile.Exists) + { + _log.LogInformation("Running on CI after a build that produced no {File}, skipping the validation", resolvedFile.FullName); + return 0; + } + if (runningOnCi && !Paths.TryFindDocsFolderFromRoot(fs, root, out _, out _)) + { + _log.LogInformation("Running on CI, {Directory} has no documentation, skipping the validation", root.FullName); + return 0; + } + + _log.LogInformation("Validating {File} in {Directory}", file, root.FullName); + await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); - await _linkIndexLinkChecker.CheckWithLocalLinksJson(collector, repository, file, ctx); + await _linkIndexLinkChecker.CheckWithLocalLinksJson(collector, repository, resolvedFile.FullName, ctx); await collector.StopAsync(ctx); return collector.Errors; } diff --git a/src/tooling/docs-builder/Cli/Commands.cs b/src/tooling/docs-builder/Cli/Commands.cs index 242e51a19..0624ff346 100644 --- a/src/tooling/docs-builder/Cli/Commands.cs +++ b/src/tooling/docs-builder/Cli/Commands.cs @@ -125,8 +125,8 @@ public async Task Generate( CanonicalBaseUrl = canonicalBaseUri }; } - // On CI, we are running on merge commit which may have changes against an older - // docs folder (this can happen on out of date PR's). + // On CI, we are running on a merge commit which may have changes against an older + // docs folder (this can happen on out-of-date PR's). // At some point in the future we can remove this try catch catch (Exception e) when (runningOnCi && e.Message.StartsWith("Can not locate docset.yml file in")) { diff --git a/src/tooling/docs-builder/Cli/InboundLinkCommands.cs b/src/tooling/docs-builder/Cli/InboundLinkCommands.cs index 23fa5f2c4..7da8ecda2 100644 --- a/src/tooling/docs-builder/Cli/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Cli/InboundLinkCommands.cs @@ -18,13 +18,13 @@ namespace Documentation.Builder.Cli; internal sealed class InboundLinkCommands(ILoggerFactory logger, ICoreService githubActionsService) { private readonly LinkIndexLinkChecker _linkIndexLinkChecker = new(logger); + private readonly ILogger _log = logger.CreateLogger(); [SuppressMessage("Usage", "CA2254:Template should be a static expression")] private void AssignOutputLogger() { - var log = logger.CreateLogger(); - ConsoleApp.Log = msg => log.LogInformation(msg); - ConsoleApp.LogError = msg => log.LogError(msg); + ConsoleApp.Log = msg => _log.LogInformation(msg); + ConsoleApp.LogError = msg => _log.LogError(msg); } /// Validate all published cross_links in all published links.json files. @@ -69,21 +69,38 @@ public async Task ValidateRepoInboundLinks(string? from = null, string? to /// Validate a locally published links.json file against all published links.json files in the registry /// /// Path to `links.json` defaults to '.artifacts/docs/html/links.json' + /// -p, Defaults to the `{pwd}` folder /// [Command("validate-link-reference")] [ConsoleAppFilter] [ConsoleAppFilter] - public async Task ValidateLocalLinkReference([Argument] string? file = null, Cancel ctx = default) + public async Task ValidateLocalLinkReference(string? file = null, string? path = null, Cancel ctx = default) { AssignOutputLogger(); file ??= ".artifacts/docs/html/links.json"; var fs = new FileSystem(); - var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var root = !string.IsNullOrEmpty(path) ? fs.DirectoryInfo.New(path) : fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var repository = GitCheckoutInformation.Create(root, new FileSystem(), logger.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName ?? throw new Exception("Unable to determine repository name"); + var resolvedFile = fs.FileInfo.New(Path.Combine(root.FullName, file)); + + var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + if (runningOnCi && !resolvedFile.Exists) + { + _log.LogInformation("Running on CI after a build that produced no {File}, skipping the validation", resolvedFile.FullName); + return 0; + } + if (runningOnCi && !Paths.TryFindDocsFolderFromRoot(fs, root, out _, out _)) + { + _log.LogInformation("Running on CI, {Directory} has no documentation, skipping the validation", root.FullName); + return 0; + } + + _log.LogInformation("Validating {File} in {Directory}", file, root.FullName); + await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); - await _linkIndexLinkChecker.CheckWithLocalLinksJson(collector, repository, file, ctx); + await _linkIndexLinkChecker.CheckWithLocalLinksJson(collector, repository, resolvedFile.FullName, ctx); await collector.StopAsync(ctx); return collector.Errors; }