From 2cf240d23feedfd3922041c572ef492dad67c908 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 21 Aug 2026 13:38:08 -0500 Subject: [PATCH] Allow .artifacts for changelog CI I/O --- .../FileSystems/ChangelogFileSystem.cs | 7 +- .../FileSystems/RunnerTempFileSystem.cs | 6 +- .../Evaluation/ChangelogCiFileSystemTests.cs | 94 +++++++++++++++++++ .../ChangelogPrepareArtifactServiceTests.cs | 32 +++++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogCiFileSystemTests.cs diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs index 266d91f06..db4abf0c7 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs @@ -10,13 +10,14 @@ namespace Elastic.Documentation.FileSystems; /// /// Scope for changelog commands: the git root of the target repository. -/// Allows reading .git metadata (remote URL, branch); does not include -/// AppData or build artifacts — changelog operates only within the repo working tree. +/// Allows reading .git metadata (remote URL, branch) and writing under +/// the conventional .artifacts CI staging directory. Does not include +/// AppData — changelog operates only within the repo working tree. /// public class ChangelogFileSystem(IDirectoryInfo root, IFileSystem? inner = null) : ScopedFileSystem(inner ?? Physical, new ScopedFileSystemOptions([root.FullName]) { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } }), IChangelogFileSystem diff --git a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs index 516087144..fb856bfbf 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs @@ -15,7 +15,9 @@ namespace Elastic.Documentation.FileSystems; /// /// Used by evaluate-pr, evaluate-artifact, and prepare-artifact — /// all three operate on paths vended by the CI environment, not a fixed docset or checkout. -/// Permits .git so changelog configuration can be located by the config loader. +/// Permits .git so changelog configuration can be located by the config loader, +/// and .artifacts so CI staging/artifact paths under the conventional build +/// output directory are writable. /// /// public class RunnerTempFileSystem( @@ -49,7 +51,7 @@ private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo workingRoot, return new ScopedFileSystemOptions([.. roots]) { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } }; } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCiFileSystemTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCiFileSystemTests.cs new file mode 100644 index 000000000..860c230e1 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCiFileSystemTests.cs @@ -0,0 +1,94 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; +using Nullean.ScopedFileSystem; + +namespace Elastic.Changelog.Tests.Evaluation; + +/// +/// Regression: changelog CI staging uses .artifacts/changelog-* under the checkout. +/// ScopedFileSystem rejects hidden path segments unless they are allowlisted. +/// +public class ChangelogCiFileSystemTests +{ + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + + [Fact] + public void ChangelogFileSystem_AllowsArtifactsStagingAndArtifactDirs() + { + var mock = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Root }); + var fs = ChangelogFileSystem.FromWorkingDirectory(mock); + + var staging = Path.Join(Root, ".artifacts", "changelog-staging"); + var artifact = Path.Join(Root, ".artifacts", "changelog-artifact"); + var stagingFile = Path.Join(staging, "42.yaml"); + var artifactFile = Path.Join(artifact, "metadata.json"); + + var create = () => + { + fs.Directory.CreateDirectory(staging); + fs.Directory.CreateDirectory(artifact); + fs.File.WriteAllText(stagingFile, "title: test"); + fs.File.WriteAllText(artifactFile, "{}"); + }; + + create.Should().NotThrow(); + fs.File.Exists(stagingFile).Should().BeTrue(); + fs.File.Exists(artifactFile).Should().BeTrue(); + } + + [Fact] + public void ChangelogFileSystem_BlocksOtherHiddenDirectories() + { + var mock = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Root }); + var fs = ChangelogFileSystem.FromWorkingDirectory(mock); + var hidden = Path.Join(Root, ".hidden", "nested"); + + var create = () => fs.Directory.CreateDirectory(hidden); + + create.Should().Throw() + .WithMessage("*hidden*"); + } + + [Fact] + public void RunnerTempFileSystem_AllowsArtifactsStagingAndArtifactDirs() + { + var mock = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Root }); + var fs = new RunnerTempFileSystem(mock.DirectoryInfo.New(Root), inner: mock); + + var staging = Path.Join(Root, ".artifacts", "changelog-staging"); + var artifact = Path.Join(Root, ".artifacts", "changelog-artifact"); + var stagingFile = Path.Join(staging, "42.yaml"); + var artifactFile = Path.Join(artifact, "metadata.json"); + + var create = () => + { + fs.Directory.CreateDirectory(staging); + fs.Directory.CreateDirectory(artifact); + fs.File.WriteAllText(stagingFile, "title: test"); + fs.File.WriteAllText(artifactFile, "{}"); + }; + + create.Should().NotThrow(); + fs.File.Exists(stagingFile).Should().BeTrue(); + fs.File.Exists(artifactFile).Should().BeTrue(); + } + + [Fact] + public void RunnerTempFileSystem_BlocksOtherHiddenDirectories() + { + var mock = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Root }); + var fs = new RunnerTempFileSystem(mock.DirectoryInfo.New(Root), inner: mock); + var hidden = Path.Join(Root, ".hidden", "nested"); + + var create = () => fs.Directory.CreateDirectory(hidden); + + create.Should().Throw() + .WithMessage("*hidden*"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index bff6350b2..17b86ae26 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -292,6 +292,38 @@ public async Task PrepareArtifact_MissingStagingYaml_StatusError() metadata.Status.Should().Be("error"); } + [Fact] + public async Task PrepareArtifact_ArtifactsPaths_CopiesYamlAndWritesMetadata() + { + // Regression: docs-actions stages under .artifacts/changelog-*. Without + // .artifacts on the ScopedFileSystem allowlist, CreateDirectory throws + // even when generation was skipped. + var stagingDir = Path.Join(Root, ".artifacts", "changelog-staging"); + var outputDir = Path.Join(Root, ".artifacts", "changelog-artifact"); + await SetupConfig(); + FileSystem.Directory.CreateDirectory(stagingDir); + await FileSystem.File.WriteAllTextAsync( + Path.Join(stagingDir, "42.yaml"), + "title: test changelog", + TestContext.Current.CancellationToken); + + var service = CreateService(); + var args = DefaultArgs() with + { + StagingDir = stagingDir, + OutputDir = outputDir + }; + + var result = await service.PrepareArtifact(Collector, args, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + FileSystem.File.Exists(Path.Join(outputDir, "42.yaml")).Should().BeTrue(); + var json = FileSystem.File.ReadAllText(Path.Join(outputDir, "metadata.json")); + var metadata = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; + metadata.Status.Should().Be("success"); + metadata.ChangelogFilename.Should().Be("42.yaml"); + } + [Fact] public async Task PrepareArtifact_WithBomPrefixedYaml_NormalizesOutput() {