Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `# <PackageId>`, 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<run>`, pull requests `1.4.0-pr.<n>.<run>`, 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.
Expand Down
8 changes: 5 additions & 3 deletions docs/package-readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. **`<!-- nuget:skip -->` … `<!-- /nuget:skip -->`** blocks are cut: CI badges, contributing notes, build setup.
3. **`<!-- nuget:only <PackageId> -->` … `<!-- /nuget:only -->`** 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 `# <PackageId>`, 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.

Expand Down Expand Up @@ -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/<Configuration>/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:

Expand Down
2 changes: 2 additions & 0 deletions kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
host; set one empty in the csproj (or -p:MSKit_ReleasesUrl=) to leave the link out. -->
<MSKit_ReleasesUrl Condition="'$(MSKit_ReleasesUrl)'==''">auto</MSKit_ReleasesUrl>
<MSKit_IssuesUrl Condition="'$(MSKit_IssuesUrl)'==''">auto</MSKit_IssuesUrl>
<!-- The generated readme's first level-1 heading; `auto` is the PackageId, empty keeps the README's. -->
<MSKit_PackageReadmeTitle Condition="'$(MSKit_PackageReadmeTitle)'==''">auto</MSKit_PackageReadmeTitle>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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)')" />
Expand Down Expand Up @@ -91,6 +92,7 @@
RawTemplate="$(MSKit_RepoRawUrlTemplate)"
ReleasesUrl="$(MSKit_ReleasesUrl)"
IssuesUrl="$(MSKit_IssuesUrl)"
ReadmeTitle="$(MSKit_PackageReadmeTitle)"
Visibility="$(_MSKit_RepositoryVisibility)"
AllowedImageHosts="$(_MSKit_AllowedImageHosts)"
GitHubHosts="@(SourceLinkGitHubHost)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion kit/.toolkit/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand All @@ -219,4 +222,7 @@ if [ ! -f "$dbt" ]; then
elif ! grep -q '\.toolkit[/\\]msbuild[/\\]init\.targets' "$dbt"; then
say "add to Directory.Build.targets: <Import Project=\"\$(MSBuildThisFileDirectory).toolkit/msbuild/init.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
}
9 changes: 9 additions & 0 deletions tests/fixtures/PackageReadme/titles/fenced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
An intro paragraph before any heading.

```markdown
# Fenced Heading
```

# Real Heading

FENCED-BODY text.
5 changes: 5 additions & 0 deletions tests/fixtures/PackageReadme/titles/no-h1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
A README without a level-1 heading.

## Usage

NO-H1-BODY text.
13 changes: 13 additions & 0 deletions tests/fixtures/PackageReadme/titles/scoped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- nuget:skip -->
# GitHub Heading

[![CI](https://img.shields.io/badge/ci-passing-green)](https://github.com/DragoAnt/Fixture/actions)
<!-- /nuget:skip -->

<!-- nuget:only DragoAnt.Fixture.Observer.Http -->
# Http Heading
<!-- /nuget:only -->

# Shared Heading

SCOPED-BODY text.
8 changes: 8 additions & 0 deletions tests/fixtures/PackageReadme/titles/setext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Setext Heading
==============

SETEXT-BODY text.

## Usage

Usage text.
47 changes: 47 additions & 0 deletions tests/package-readme.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions tests/update-self.sh
Original file line number Diff line number Diff line change
@@ -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
Loading