diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d35a84..08df380 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,33 @@ jobs: - name: List produced packages run: ls -la nupkgs/ + # ─── Pre-push safety: refuse if the version is already on nuget.org ─── + # NuGet locks version numbers permanently after first publish. Without + # this check, `--skip-duplicate` in the push step would silently no-op + # the upload of a fresh binary when the version already exists — burned + # us once (see CHANGELOG 0.1.0-alpha → 0.1.1-alpha). Fails loud so the + # human bumps Directory.Build.props before re-tagging. + - name: Verify version is not already published + if: github.event_name == 'push' || inputs.dry_run == false + run: | + version=$(grep -oE '[^<]+' Directory.Build.props | head -1 | sed 's///') + echo "Directory.Build.props version: $version" + if [ -z "$version" ]; then + echo "ERROR: could not read Version from Directory.Build.props" + exit 1 + fi + for pkg in ShellDocs.CLI ShellDocs.Components ShellDocs.Core ShellDocs.Markdown ShellDocs.Templates ShellDocs.Tokens; do + lower=$(echo "$pkg" | tr '[:upper:]' '[:lower:]') + existing=$(curl -sf "https://api.nuget.org/v3-flatcontainer/${lower}/index.json" 2>/dev/null | grep -oE '"[^"]*"' | tr -d '"' || echo "") + if echo "$existing" | grep -Fxq "$version"; then + echo "ERROR: $pkg $version is already published on nuget.org." + echo "NuGet does not allow overwriting a published version." + echo "Bump in Directory.Build.props and re-tag." + exit 1 + fi + done + echo "OK — $version is not yet on nuget.org for any of the 6 packages. Proceeding." + # Runs immediately before push — the temp API key is valid only 1 hour. # `user` is the nuget.org profile name (NOT email, NOT the GH org name), # kept as a secret so it never lives in the workflow file. diff --git a/CHANGELOG.md b/CHANGELOG.md index 140ef1d..602e7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.1-alpha] — 2026-07-25 + +First point-release after the dogfood smoke of `0.1.0-alpha`. Three consumer-blocking fixes plus release-workflow hardening. + +### Fixed + +- **`NavigationGraphBuilder` now auto-includes `.md` files not referenced in `meta.json`.** Previously, when `meta.json` existed, ONLY the entries in its `pages` array made it into the nav — every other file on disk was silently dropped. `shelldocs add component Button` created `content/docs/components/button.md` on disk but the URL 404'd and the page never appeared in the sidebar until the consumer hand-edited `meta.json`. Fix: `meta.json` now controls ORDERING of explicitly-listed items; presence is driven by the file tree. Unreferenced files/folders get appended alphabetically after the explicit ordering. Backward-compatible — consumers who list everything explicitly get their exact ordering preserved verbatim before the auto-appended tail. +- **`shelldocs init` scaffold no longer emits a broken `` example.** The intro-page template referenced a `Text` prop that doesn't exist on ``; the current API is `Variant` + `Title` + `ChildContent`. Every new consumer running `dotnet run` on their fresh scaffold saw an empty callout as the first thing on their site. Template updated to `body content`. +- **`shelldocs init` now inserts a Content Update itemgroup so `dotnet publish` copies the markdown corpus.** Previously worked on `dotnet run` (resolves ContentRoot to source) but silently broke first deploy — the published output had zero markdown, so every `/docs/*` route 404'd. New `AddContentCopyIfMissing` helper adds `` to the consumer's csproj. Idempotent, runs in both CREATE and ATTACH modes. + +### Hardened (release infrastructure) + +- **Release workflow pre-push existence check.** New step queries `nuget.org/v3-flatcontainer` for each of the 6 package IDs at the tag's version before invoking `dotnet nuget push`. If any version already exists on nuget.org, the workflow **fails loud** with a "bump `Directory.Build.props` and re-tag" message. `--skip-duplicate` stays in the push step (still useful for resuming a workflow re-run that partially completed), but the pre-check catches the "you forgot to bump the version number" case explicitly instead of silently no-op'ing. + ## [0.1.0-alpha] — 2026-07-25 First public release. The whole Phase 1 target is shipped, plus most of Phase 2's primitives + consumer DX polish. See [ROADMAP.md](docs/ROADMAP.md). @@ -95,5 +109,6 @@ Published to NuGet: - `` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4) - No `` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders -[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.0-alpha...HEAD +[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.1-alpha...HEAD +[0.1.1-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.1-alpha [0.1.0-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.0-alpha diff --git a/Directory.Build.props b/Directory.Build.props index 5ac72a8..6e5ca5f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ - 0.1.0-alpha + 0.1.1-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b1498cc..5012ecd 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -66,7 +66,10 @@ Once the one-time setup is done, cutting a release is three commands. ### 1. Bump the version -Edit `Directory.Build.props` → `0.X.Y[-suffix]`. That propagates to every packable project via the shared props file. +Bump **two** places to the same value: + +- `Directory.Build.props` → `0.X.Y[-suffix]` — propagates to every packable project via the shared props file +- `src/ShellDocs.CLI/Commands/InitCommand.cs` → `private const string ShellDocsVersion = "0.X.Y[-suffix]";` — determines which `ShellDocs.*` package versions the CLI's `shelldocs init` scaffold references. If left stale, consumers scaffolding via the new CLI get old packages that lack the fresh CLI's fixes. For a prerelease bump: `0.1.0-alpha` → `0.1.1-alpha` (patch) or `0.2.0-alpha` (minor). For the first stable: strip the `-alpha` suffix → `1.0.0`. diff --git a/src/ShellDocs.CLI/Commands/InitCommand.cs b/src/ShellDocs.CLI/Commands/InitCommand.cs index c16dd29..0a0b867 100644 --- a/src/ShellDocs.CLI/Commands/InitCommand.cs +++ b/src/ShellDocs.CLI/Commands/InitCommand.cs @@ -20,7 +20,10 @@ SHELLDOCS_SETUP.md with copy-paste snippets instead. Both modes are idempotent. */ internal static class InitCommand { - private const string ShellDocsVersion = "0.1.0-alpha"; + // Bump with in Directory.Build.props on every release. Determines + // which ShellDocs.* versions the scaffold references. If stale, consumers + // scaffolding via a new CLI get old packages that lack the fresh CLI's fixes. + private const string ShellDocsVersion = "0.1.1-alpha"; public static int Run(string? path, string dir, bool attach, bool yes, string theme) { @@ -252,6 +255,29 @@ private static void ScaffoldPackages(string csproj, List changes) { AddPackageIfMissing(csproj, "ShellDocs.Components", ShellDocsVersion, changes); AddPackageIfMissing(csproj, "ShellDocs.Tokens", ShellDocsVersion, changes); + AddContentCopyIfMissing(csproj, changes); + } + + // Adds a Content Update item for content markdown/meta.json so + // `dotnet publish` copies the markdown corpus into the publish output. + // Without this, ContentRoot resolves fine under `dotnet run` (source dir) + // but the published site has no content to render. + internal static void AddContentCopyIfMissing(string csproj, List changes) + { + var xml = File.ReadAllText(csproj); + if (new Regex(@"", StringComparison.OrdinalIgnoreCase); + if (closing < 0) return; + + var block = + $" {Environment.NewLine}" + + $" {Environment.NewLine}" + + $" {Environment.NewLine}{Environment.NewLine}"; + + File.WriteAllText(csproj, xml.Insert(closing, block)); + changes.Add($"added [cyan]content copy-to-output[/] to {Path.GetFileName(csproj)}"); } private static void ScaffoldContent(string root, List changes) diff --git a/src/ShellDocs.Core/NavigationGraphBuilder.cs b/src/ShellDocs.Core/NavigationGraphBuilder.cs index 34458d8..9593e47 100644 --- a/src/ShellDocs.Core/NavigationGraphBuilder.cs +++ b/src/ShellDocs.Core/NavigationGraphBuilder.cs @@ -59,11 +59,40 @@ private static List BuildFolder(string folder, string root, stri } var result = new List(); + var consumedSlugs = new HashSet(StringComparer.OrdinalIgnoreCase); + var consumedFolders = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in meta.Pages) { - var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix); + var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders); if (node is not null) result.Add(node); } + + /* meta.json controls ORDERING for anything it lists; presence is + driven by the file tree. Files or subfolders the meta didn't + mention get appended at the end (alphabetically) so a page dropped + via `shelldocs add` or by hand appears immediately, without the + consumer touching meta.json. */ + foreach (var (name, tuple) in folderNameToChildren.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + if (consumedFolders.Contains(name)) continue; + var section = new NavigationNode + { + Title = TitleFromFolderName(name), + Kind = NodeKind.Section, + Path = tuple.folderPath + }; + LinkChildren(section, tuple.children); + result.Add(section); + } + foreach (var page in slugToNode + .Where(kv => !consumedSlugs.Contains(kv.Key)) + .Select(kv => kv.Value) + .OrderBy(n => n.Order) + .ThenBy(n => n.Title, StringComparer.OrdinalIgnoreCase)) + { + result.Add(page); + } + return result; } @@ -72,14 +101,21 @@ private static List BuildFolder(string folder, string root, stri Dictionary slugToNode, Dictionary children)> folderNameToChildren, string root, - string urlPrefix) + string urlPrefix, + HashSet consumedSlugs, + HashSet consumedFolders) { switch (entry) { case MetaJsonPageRef pageRef: - if (slugToNode.TryGetValue(pageRef.Slug, out var page)) return page; + if (slugToNode.TryGetValue(pageRef.Slug, out var page)) + { + consumedSlugs.Add(pageRef.Slug); + return page; + } if (folderNameToChildren.TryGetValue(pageRef.Slug, out var folder)) { + consumedFolders.Add(pageRef.Slug); var section = new NavigationNode { Title = TitleFromFolderName(pageRef.Slug), @@ -103,7 +139,7 @@ private static List BuildFolder(string folder, string root, stri var subChildren = new List(); foreach (var e in sub.Pages) { - var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix); + var child = ResolveEntry(e, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders); if (child is not null) subChildren.Add(child); } LinkChildren(subNode, subChildren); diff --git a/src/ShellDocs.Templates/ScaffoldTemplates.cs b/src/ShellDocs.Templates/ScaffoldTemplates.cs index 35b4529..f53a434 100644 --- a/src/ShellDocs.Templates/ScaffoldTemplates.cs +++ b/src/ShellDocs.Templates/ScaffoldTemplates.cs @@ -25,7 +25,9 @@ public static class ScaffoldTemplates ## Live components ```razor:preview - + + This block is a live Blazor component rendered from markdown. + ``` """; diff --git a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs index 97ea95b..9c75b34 100644 --- a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs +++ b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs @@ -139,6 +139,67 @@ public void Build_UnknownSlugInMetaJson_IsSilentlySkipped() Assert.Equal(new[] { "Real" }, titles); } + [Fact] + public void Build_MdFileNotInMetaJson_IsAppendedAfterExplicitOrdering() + { + // Meta lists only `alpha`, but `bravo.md` exists on disk. + // Bravo should surface at the end, not silently disappear (the + // `shelldocs add` DX gap fix). + WriteMd("alpha.md", "Alpha"); + WriteMd("bravo.md", "Bravo"); + WriteMeta("", """{ "pages": ["alpha"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "Alpha", "Bravo" }, titles); + } + + [Fact] + public void Build_SubfolderNotInMetaJson_AppearsAsSectionAfterExplicitOrdering() + { + WriteMd("intro.md", "Intro"); + WriteMd("components/button.md", "Button"); + WriteMeta("", """{ "pages": ["intro"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "Intro", "Components" }, titles); + } + + [Fact] + public void Build_UnreferencedItems_AreAlphabetical() + { + WriteMd("first.md", "First"); // in meta + WriteMd("charlie.md", "Charlie"); // not in meta + WriteMd("alpha.md", "Alpha"); // not in meta + WriteMd("bravo.md", "Bravo"); // not in meta + WriteMeta("", """{ "pages": ["first"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "First", "Alpha", "Bravo", "Charlie" }, titles); + } + + [Fact] + public void Build_ExplicitOrderingIsPreserved_ForItemsInMetaJson() + { + // Verify the auto-append doesn't break the existing "meta.json controls + // ordering for explicitly-listed items" contract. + WriteMd("alpha.md", "Alpha"); + WriteMd("bravo.md", "Bravo"); + WriteMd("charlie.md", "Charlie"); + WriteMeta("", """{ "pages": ["charlie", "alpha"] }"""); + + var graph = NavigationGraphBuilder.Build(_root); + var titles = graph.Root.Children.Select(c => c.Title).ToList(); + + // Charlie + Alpha in the meta-specified order, THEN Bravo appended. + Assert.Equal(new[] { "Charlie", "Alpha", "Bravo" }, titles); + } + [Fact] public void Build_ThrowsOnMissingContentRoot() {