diff --git a/CHANGELOG.md b/CHANGELOG.md index 370ebe3..516cf1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Added + +- `MSKit_PackageReadmeTitle`: the readme generated from `MSKit_PackageReadmeFrom` replaces the README's first level-1 heading with `# `, so each package of a multi-package repository is titled on its own page; a README without one gets the title as its first line. Set another title, or empty to keep the README's heading. See [docs/package-readme.md](./docs/package-readme.md). + +### Fixed + +- `update.sh` no longer stops halfway (`unexpected EOF`, `kit.json` left at the old version) when the update replaces it with a copy of another length, as a CRLF checkout does: the script is parsed in full before it runs, and it copies the new `update.sh` / `update.ps1` as its last step. The fix applies to updates started from this version on. An update from 0.2.0 or earlier still runs the old script: it installs the files, then stops with a `syntax error` before writing `kit.json`; run the same command once more. + ## [0.2.0] - 2026-10-05 ### Added diff --git a/README.md b/README.md index 2008df9..da3035d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Shared MSBuild settings for .NET repositories that publish NuGet packages: nuget ## Features - **Packages that pass nuget.org's rules by default.** Licence, icon, README, Source Link, symbols (`.snupkg`), repository and release-notes links, package validation and NuGet audit are set for every packable project. Nineteen checks (`MSKIT_PKG001`-`019`) run on `dotnet pack`: warnings on your machine, errors on CI. -- **One README for the repository and its packages.** `MSKit_PackageReadmeFrom=README.md` generates each package's readme on `dotnet pack`: relative links pinned to the commit on GitHub, GitLab and other hosts, per-package sections, release and issue links. See [docs/package-readme.md](./docs/package-readme.md). +- **One README for the repository and its packages.** `MSKit_PackageReadmeFrom=README.md` generates each package's readme on `dotnet pack`: relative links pinned to the commit on GitHub, GitLab and other hosts, per-package sections, the package id as the title, release and issue links. See [docs/package-readme.md](./docs/package-readme.md). - **Versions from release tags.** Publish a GitHub release `v1.4.0` and the packages are `1.4.0`. Branch builds are `1.4.0-ci.`, pull requests `1.4.0-pr..`, local builds `9999.0.0`. A tag that is not SemVer stops the build with `MSKIT_VER006`. - **Tests on Microsoft.Testing.Platform v2.** Projects named `*.Tests` become xUnit v3 test projects with code coverage (Cobertura), TRX and JUnit reports, assertions and NSubstitute wired in, and `InternalsVisibleTo` from the code they test. - **Plain files, one update script.** The kit lives in your repository's `.toolkit/` folder, so every change shows up in a pull request. `update.ps1` / `update.sh` installs a release after checking its SHA-256. diff --git a/docs/package-readme.md b/docs/package-readme.md index 4ff4ed4..c965196 100644 --- a/docs/package-readme.md +++ b/docs/package-readme.md @@ -16,8 +16,9 @@ The path is relative to the git root (or absolute). It wins over a `package.read 1. **Relative links and images become absolute URLs pinned to the commit** (`RepositoryCommit`, which Source Link fills in for every pack; a release tag may not exist yet). A link goes to the file view, a link to a folder to the folder view, an image to the raw file. Anchors (`#install`), absolute URLs, and anything inside inline code or a fenced block are left alone. A root-relative path (`/docs/x.md`) is taken from the repository root, as GitHub does. 2. **`` … ``** blocks are cut: CI badges, contributing notes, build setup. 3. **`` … ``** blocks stay only in the readme of the packages they name (several ids: separate with spaces or commas). Unmarked content goes to every package, so a multi-package repository keeps one README. -4. **Release notes and Issues links** are added to the overview when it does not already link those pages: the `## Overview` section when there is one, else the text above the first `##` heading. -5. The `MSKIT_PKG` checks then run on the generated file, so a link the generator could not rewrite is still caught by `MSKIT_PKG017` / `MSKIT_PKG009`. +4. **The title is the package id.** The first level-1 heading (`# …`, or a `===`-underlined one) becomes `# `, so each package of a multi-package repository is named on its own page; a README with no level-1 heading gets the title as its first line. Only the content this package keeps counts: a heading inside a `nuget:skip` block, another package's `nuget:only` block or a code fence is never the one replaced. Set `MSKit_PackageReadmeTitle` to another title, or empty to keep the README's own heading. +5. **Release notes and Issues links** are added to the overview when it does not already link those pages: the `## Overview` section when there is one, else the text above the first `##` heading. +6. The `MSKIT_PKG` checks then run on the generated file, so a link the generator could not rewrite is still caught by `MSKIT_PKG017` / `MSKIT_PKG009`. The markers are matched on lines of their own, outside code fences. A README that documents the markers in a fenced block is safe. @@ -53,11 +54,12 @@ A self-hosted GitLab is recognised through the same item Source Link uses: | `MSKit_RepoRawUrlTemplate` | the provider's | Images | | `MSKit_ReleasesUrl` | `auto` | The release-notes page, also the default `PackageReleaseNotes` off GitHub; empty leaves the link out | | `MSKit_IssuesUrl` | `auto` | The issues page; empty leaves the link out | +| `MSKit_PackageReadmeTitle` | `auto` (the `PackageId`) | The text of the first level-1 heading; empty keeps the README's own heading | | `MSKit_RepositoryVisibility` | `$(CI_PROJECT_VISIBILITY)` | `private` or `internal` raises `MSKIT_PKG022` | | `MSKit_GeneratedPackageReadmePath` | `obj//package.readme.md` | Where the generated file goes | | `MSKit_PackageReadmeAllowedImageHosts` | the kit's list | `;`-separated hosts that replace the list below | -Templates take `{repoUrl}`, `{host}`, `{repoPath}` (`owner/repo`, nested groups included), `{owner}`, `{repo}`, `{commit}` and `{path}`. To leave a link out, set the property empty in the csproj or with `-p:MSKit_IssuesUrl=`: an empty value in `Directory.Build.props` above the kit import is replaced by `auto`. +Templates take `{repoUrl}`, `{host}`, `{repoPath}` (`owner/repo`, nested groups included), `{owner}`, `{repo}`, `{commit}` and `{path}`. To leave a link out, or keep the README's heading, set the property empty in the csproj, below the kit import, or with `-p:MSKit_IssuesUrl=`: an empty value in `Directory.Build.props` above the kit import is replaced by `auto`. GitHub Actions does not expose the repository's visibility as a variable; pass it if you want the private-repository warning there: diff --git a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props index 6cd5c67..91ed9f6 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props @@ -20,6 +20,8 @@ host; set one empty in the csproj (or -p:MSKit_ReleasesUrl=) to leave the link out. --> auto auto + + auto diff --git a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets index c94651e..38995aa 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets @@ -62,6 +62,7 @@ <_MSKit_ReadmeStampLine Include="raw=$(MSKit_RepoRawUrlTemplate)" /> <_MSKit_ReadmeStampLine Include="releases=$(MSKit_ReleasesUrl)" /> <_MSKit_ReadmeStampLine Include="issues=$(MSKit_IssuesUrl)" /> + <_MSKit_ReadmeStampLine Include="title=$(MSKit_PackageReadmeTitle)" /> <_MSKit_ReadmeStampLine Include="visibility=$(_MSKit_RepositoryVisibility)" /> <_MSKit_ReadmeStampLine Include="hosts=$(_MSKit_AllowedImageHosts)" /> <_MSKit_ReadmeStampLine Include="@(SourceLinkGitHubHost->'github=%(Identity)')" /> @@ -91,6 +92,7 @@ RawTemplate="$(MSKit_RepoRawUrlTemplate)" ReleasesUrl="$(MSKit_ReleasesUrl)" IssuesUrl="$(MSKit_IssuesUrl)" + ReadmeTitle="$(MSKit_PackageReadmeTitle)" Visibility="$(_MSKit_RepositoryVisibility)" AllowedImageHosts="$(_MSKit_AllowedImageHosts)" GitHubHosts="@(SourceLinkGitHubHost)" diff --git a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt index d590633..d0d5ddf 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt @@ -128,6 +128,7 @@ public sealed class MSKit_PackageAssets : Task public string RawTemplate { get; set; } public string ReleasesUrl { get; set; } public string IssuesUrl { get; set; } + public string ReadmeTitle { get; set; } public string Visibility { get; set; } public string GitHubHosts { get; set; } public string GitLabHosts { get; set; } @@ -370,6 +371,34 @@ public sealed class MSKit_PackageAssets : Task if (!canRewrite && unresolved > 0) report("MSKIT_PKG020", display + ": " + unresolved + " relative link(s) and image(s) stay relative because " + why + "."); + var title = norm(ReadmeTitle) == "auto" ? norm(PackageId) : norm(ReadmeTitle); + if (title.Length > 0) + { + var anyAtx = new Regex(@"^ {0,3}#{1,6}(?:[ \t]|$)"); + var h1Atx = new Regex(@"^ {0,3}#(?:[ \t]|$)"); + var setextH1 = new Regex(@"^ {0,3}=+[ \t]*$"); + int from = -1, to = -1; + for (var i = 0; i < outLines.Count && from < 0; i++) + { + if (outFence[i]) continue; + if (h1Atx.IsMatch(outLines[i])) { from = to = i; continue; } + if (i + 1 < outLines.Count && !outFence[i + 1] && outLines[i].Trim().Length > 0 && !anyAtx.IsMatch(outLines[i]) && setextH1.IsMatch(outLines[i + 1])) + { + from = i; to = i + 1; + while (from > 0 && !outFence[from - 1] && outLines[from - 1].Trim().Length > 0 && !anyAtx.IsMatch(outLines[from - 1])) from--; + } + } + if (from >= 0) + { + outLines.RemoveRange(from, to - from + 1); outFence.RemoveRange(from, to - from + 1); + outLines.Insert(from, "# " + title); outFence.Insert(from, false); + } + else + { + outLines.InsertRange(0, new[] { "# " + title, "" }); outFence.InsertRange(0, new[] { false, false }); + } + } + var releasesUrl = norm(ReleasesUrl) == "auto" ? (hasRepo && releases.Length > 0 ? expand(releases, "") : "") : expand(norm(ReleasesUrl), ""); var issuesUrl = norm(IssuesUrl) == "auto" ? (hasRepo && issues.Length > 0 ? expand(issues, "") : "") : expand(norm(IssuesUrl), ""); var start = -1; diff --git a/kit/.toolkit/update.sh b/kit/.toolkit/update.sh index 9cec564..e1cc11c 100644 --- a/kit/.toolkit/update.sh +++ b/kit/.toolkit/update.sh @@ -2,6 +2,9 @@ # Installs or updates DragoAnt.MSBuildKit in a repository's .toolkit/ folder. # Usage: sh .toolkit/update.sh [--version X.Y.Z] [--add PART] [--remove PART] [--dry-run] # [--source DIR|ZIP] [--sha256 HEX] [--repo OWNER/NAME] [--root DIR] + +# One brace group, parsed in full before it runs: the update may replace this file while it runs. +{ set -eu repo="DragoAnt/MSBuildKit" @@ -198,7 +201,7 @@ mkdir -p "$toolkit" rm -rf "$toolkit/msbuild" cp -R "$stage/msbuild" "$toolkit/msbuild" if [ -d "$new_toolkit/res" ]; then mkdir -p "$toolkit/res"; cp -R "$new_toolkit/res/." "$toolkit/res/"; fi -for f in update.sh update.ps1 kit.parts; do [ -f "$new_toolkit/$f" ] && cp "$new_toolkit/$f" "$toolkit/$f"; done +cp "$new_toolkit/kit.parts" "$toolkit/kit.parts" rm -f "$toolkit/kit.version" say "$changes file(s) under .toolkit/msbuild changed" @@ -219,4 +222,7 @@ if [ ! -f "$dbt" ]; then elif ! grep -q '\.toolkit[/\\]msbuild[/\\]init\.targets' "$dbt"; then say "add to Directory.Build.targets: " fi +for f in update.ps1 update.sh; do if [ -f "$new_toolkit/$f" ]; then cp "$new_toolkit/$f" "$toolkit/$f"; fi; done say "done: DragoAnt.MSBuildKit $version installed in $toolkit" +exit 0 +} diff --git a/tests/fixtures/PackageReadme/titles/fenced.md b/tests/fixtures/PackageReadme/titles/fenced.md new file mode 100644 index 0000000..47fc369 --- /dev/null +++ b/tests/fixtures/PackageReadme/titles/fenced.md @@ -0,0 +1,9 @@ +An intro paragraph before any heading. + +```markdown +# Fenced Heading +``` + +# Real Heading + +FENCED-BODY text. diff --git a/tests/fixtures/PackageReadme/titles/no-h1.md b/tests/fixtures/PackageReadme/titles/no-h1.md new file mode 100644 index 0000000..1c15101 --- /dev/null +++ b/tests/fixtures/PackageReadme/titles/no-h1.md @@ -0,0 +1,5 @@ +A README without a level-1 heading. + +## Usage + +NO-H1-BODY text. diff --git a/tests/fixtures/PackageReadme/titles/scoped.md b/tests/fixtures/PackageReadme/titles/scoped.md new file mode 100644 index 0000000..519ef73 --- /dev/null +++ b/tests/fixtures/PackageReadme/titles/scoped.md @@ -0,0 +1,13 @@ + +# GitHub Heading + +[![CI](https://img.shields.io/badge/ci-passing-green)](https://github.com/DragoAnt/Fixture/actions) + + + +# Http Heading + + +# Shared Heading + +SCOPED-BODY text. diff --git a/tests/fixtures/PackageReadme/titles/setext.md b/tests/fixtures/PackageReadme/titles/setext.md new file mode 100644 index 0000000..8727f32 --- /dev/null +++ b/tests/fixtures/PackageReadme/titles/setext.md @@ -0,0 +1,8 @@ +Setext Heading +============== + +SETEXT-BODY text. + +## Usage + +Usage text. diff --git a/tests/package-readme.sh b/tests/package-readme.sh index dc444d8..866943c 100644 --- a/tests/package-readme.sh +++ b/tests/package-readme.sh @@ -151,3 +151,50 @@ rf_has override "[Issues](https://tracker.example.net/fixture)" # A private repository: the links will not open for package readers. rf_pack private Observer $gh -p:MSKit_RepositoryVisibility=private rf_warns private MSKIT_PKG022 + +# Title (MSKit_PackageReadmeTitle): the first level-1 heading becomes the package id, per package. +rf_first() { + actual=$(head -n 1 "$out/readme-$1.md") + [ "$actual" = "$2" ] && pass "readme $1 opens with $2" || bad "readme $1 opens with '$actual', expected '$2'" +} +rf_lines() { + n=$(grep -cxF -- "$2" "$out/readme-$1.md" || true) + [ "$n" = "$3" ] && pass "readme $1 has the line '$2' $3 time(s)" || bad "readme $1 has the line '$2' $n time(s), expected $3" +} +rf_first github "# DragoAnt.Fixture.Observer" +rf_first http "# DragoAnt.Fixture.Observer.Http" +rf_lines http "# DragoAnt.Fixture.Observer" 0 +rf_first single "# DragoAnt.Fixture.Single" +rf_pack title-keep Observer.Http $gh -p:MSKit_PackageReadmeTitle= +rf_first title-keep "# DragoAnt.Fixture.Observer" +rf_pack title-custom Observer.Http $gh "-p:MSKit_PackageReadmeTitle=Observer for HTTP" +rf_first title-custom "# Observer for HTTP" +rf_lines title-custom "# DragoAnt.Fixture.Observer" 0 + +rf_pack title-no-h1 Observer $gh -p:MSKit_PackageReadmeFrom=$rel/titles/no-h1.md +rf_first title-no-h1 "# DragoAnt.Fixture.Observer" +rf_has title-no-h1 "A README without a level-1 heading." +rf_has title-no-h1 "NO-H1-BODY" + +rf_pack title-fenced Observer $gh -p:MSKit_PackageReadmeFrom=$rel/titles/fenced.md +rf_first title-fenced "An intro paragraph before any heading." +rf_lines title-fenced "# Fenced Heading" 1 +rf_lines title-fenced "# Real Heading" 0 +rf_lines title-fenced "# DragoAnt.Fixture.Observer" 1 + +rf_pack title-setext Observer $gh -p:MSKit_PackageReadmeFrom=$rel/titles/setext.md +rf_first title-setext "# DragoAnt.Fixture.Observer" +rf_hasnt title-setext "Setext Heading" +rf_hasnt title-setext "=====" +rf_has title-setext "SETEXT-BODY" + +rf_pack title-scoped Observer $gh -p:MSKit_PackageReadmeFrom=$rel/titles/scoped.md +rf_first title-scoped "# DragoAnt.Fixture.Observer" +rf_lines title-scoped "# Shared Heading" 0 +rf_hasnt title-scoped "GitHub Heading" +rf_hasnt title-scoped "Http Heading" +rf_pack title-scoped-http Observer.Http $gh -p:MSKit_PackageReadmeFrom=$rel/titles/scoped.md +rf_first title-scoped-http "# DragoAnt.Fixture.Observer.Http" +rf_lines title-scoped-http "# Shared Heading" 1 + +git -C "$here" diff --quiet -- "$rel" && pass "packing leaves the READMEs unchanged" || bad "packing changed a README under $rel" diff --git a/tests/run.sh b/tests/run.sh index b42d5b7..cbae9c0 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -23,6 +23,7 @@ for root in "$sample" "$fixtures" "$readme_fixture" "$tfm_fixture"; do sh "$kit/.toolkit/update.sh" --source "$kit" --root "$root" > "$out/install.log" || { cat "$out/install.log"; exit 1; } done pass "update.sh installed the kit into the sample and the fixtures" +. "$here/tests/update-self.sh" lib="$sample/src/DragoAnt.Samples.MinimalLibrary/DragoAnt.Samples.MinimalLibrary.csproj" clean_env="env -u GITHUB_ACTIONS -u GITHUB_RUN_ID -u GITHUB_RUN_NUMBER -u GITHUB_REF -u GITHUB_REF_NAME -u GITHUB_REF_TYPE -u GITHUB_EVENT_NAME -u GITHUB_HEAD_REF -u GITHUB_SHA -u GITHUB_REPOSITORY" diff --git a/tests/update-self.sh b/tests/update-self.sh new file mode 100644 index 0000000..fc5dbda --- /dev/null +++ b/tests/update-self.sh @@ -0,0 +1,46 @@ +# update.sh / update.ps1 replace themselves during an update: the run that does it must still finish. +# Sourced by tests/run.sh: uses its pass, bad, $out and $kit. + +us_consumer() { + dir="$out/update-self/$1" + rm -rf "$dir"; mkdir -p "$dir/.toolkit" + printf '{\n "repository": "DragoAnt/MSBuildKit",\n "version": "0.0.1",\n "sha256": "",\n "parts": []\n}\n' > "$dir/.toolkit/kit.json" +} +# The installed copy differs in length from the new one, as an older release or a CRLF checkout does. +us_pad() { + head -n 1 "$1" + i=0; while [ $i -lt 80 ]; do echo "# padding that moves every later line of the installed copy away from the new one"; i=$((i+1)); done + tail -n +2 "$1" +} +us_check() { + name="$1"; log="$2"; status="$3" + version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$dir/.toolkit/kit.json") + [ "$status" -eq 0 ] && [ "$version" = "9.9.9" ] && grep -q "done: DragoAnt.MSBuildKit 9.9.9" "$log" \ + && pass "update $name finishes in one run (kit.json 9.9.9)" \ + || { bad "update $name: exit $status, kit.json '$version' (see $log)"; tail -n 5 "$log"; } + cmp -s "$dir/.toolkit/$4" "$kit/.toolkit/$4" && pass "update $name replaced $4" || bad "update $name left the old $4" +} + +us_consumer sh-longer +us_pad "$kit/.toolkit/update.sh" > "$dir/.toolkit/update.sh" +status=0; (cd "$dir" && sh .toolkit/update.sh --source "$kit" --version 9.9.9) > "$dir.log" 2>&1 || status=$? +us_check sh-longer "$dir.log" "$status" update.sh + +printf 'true\r\n' > "$out/update-self-cr.sh" +if sh "$out/update-self-cr.sh" > /dev/null 2>&1; then + us_consumer sh-crlf + sed 's/$/\r/' "$kit/.toolkit/update.sh" > "$dir/.toolkit/update.sh" + status=0; (cd "$dir" && sh .toolkit/update.sh --source "$kit" --version 9.9.9) > "$dir.log" 2>&1 || status=$? + us_check sh-crlf "$dir.log" "$status" update.sh +else + pass "update sh-crlf skipped: this sh does not run CRLF scripts" +fi + +if command -v pwsh > /dev/null 2>&1; then + us_consumer ps1-longer + us_pad "$kit/.toolkit/update.ps1" > "$dir/.toolkit/update.ps1" + status=0; (cd "$dir" && pwsh -NoProfile -NonInteractive -File .toolkit/update.ps1 -Source "$kit" -Version 9.9.9) > "$dir.log" 2>&1 || status=$? + us_check ps1-longer "$dir.log" "$status" update.ps1 +else + pass "update ps1-longer skipped: no pwsh" +fi