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()
{