diff --git a/.gitattributes b/.gitattributes index a0354c4..9765f6f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ * text=auto eol=lf *.png binary +*.jpg binary +*.jpeg binary *.zip binary diff --git a/CHANGELOG.md b/CHANGELOG.md index f809ef0..868b09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ All notable changes to this project are documented here. The format follows [Kee - The documentation moved from the README into [docs/](./docs/README.md), one page per topic in reading order, with a [property reference](./docs/reference/properties.md) and a [code reference](./docs/reference/codes.md) that cover everything the kit sets, reads and reports. Corrected along the way: most packaging defaults apply to every project, not only packable ones; a Roslyn project imports its role's props itself; an update rewrites more than `.toolkit/msbuild/`; any tag build is a release build. +### Fixed + +- A JPEG `PackageIconPath` no longer fails `dotnet pack` with NU5046: the icon is packed as `icon.`, lowercased (`icon.jpg`), and the nuspec names that file. `MSKIT_PKG015` accepts a 128×128 JPEG as well as a PNG, as nuget.org does ([#11](https://github.com/DragoAnt/MSBuildKit/pull/11)). + ## [0.2.1] - 2026-10-05 ### Added diff --git a/docs/packaging.md b/docs/packaging.md index 06c86c7..988d545 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -23,7 +23,7 @@ For projects with `IsPackable=True` (the owner layer makes that the default; tes | `PackageLicenseExpression` | `MIT` (owner layer), unless `PackageLicenseFile` is set | [licensing](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#licensing) | | `PackageRequireLicenseAcceptance` | `false` | | | `Authors`, `Copyright` | the owner; `Copyright (c) ` ([Build](./build.md#language-and-product-defaults)) | [copyright](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#copyright) | -| `PackageIcon` | `PackageIconPath` (owner layer: `.toolkit/res/package.icon.png`, 128×128) packed as `icon.png` | [icon](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#icon) | +| `PackageIcon` | `PackageIconPath` (owner layer: `.toolkit/res/package.icon.png`, 128×128; PNG or JPEG) packed as `icon` (`icon.png`, `icon.jpg`); a `PackageIcon` the project sets is kept | [icon](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#icon) | | `PackageReadmeFile` | generated from `MSKit_PackageReadmeFrom` ([Package readme](./package-readme.md)), else `package.readme.md` next to the csproj, else `README.md` next to it; packed as `readme.md`. `MSKit_PackageReadmeSourcePath` names another file | [README](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#readme) | | `RepositoryType`, `RepositoryUrl`, `PackageProjectUrl` | `git`; `GITHUB_SERVER_URL/GITHUB_REPOSITORY` on GitHub Actions, else the git remote [Source Link](https://learn.microsoft.com/dotnet/standard/library-guidance/sourcelink) reads, without `.git` | [repository](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#repository-type-and-url) | | `PackageReleaseNotes` | on a `https://github.com/` repository the tag's release page on a tag build, else its releases page; on other hosts the releases page the generated readme links (needs `MSKit_PackageReadmeFrom`). `MSKit_DefaultReleaseNotes=False` turns the default off | [release notes](https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices#release-notes) | @@ -54,7 +54,7 @@ Set `MSKit_PackageValidationBaselineVersion` to the last published version once | [`MSKIT_PKG012`](./reference/codes.md#mskitpkg012) | the README loads images from a host nuget.org blocks | | [`MSKIT_PKG013`](./reference/codes.md#mskitpkg013) | the version is not SemVer 2.0 (`MSKit_SemVerRegex`) | | [`MSKIT_PKG014`](./reference/codes.md#mskitpkg014) | no repository or project URL | -| [`MSKIT_PKG015`](./reference/codes.md#mskitpkg015) | the icon is not a 128×128 PNG (`MSKit_PackageIconSize`) | +| [`MSKIT_PKG015`](./reference/codes.md#mskitpkg015) | the icon is not a 128×128 PNG or JPEG (`MSKit_PackageIconSize`) | | [`MSKIT_PKG016`](./reference/codes.md#mskitpkg016) | no `PackageReleaseNotes` | | [`MSKIT_PKG017`](./reference/codes.md#mskitpkg017) | the README has relative links | | [`MSKIT_PKG018`](./reference/codes.md#mskitpkg018) | an open-source licence with an "All rights reserved" copyright | diff --git a/docs/reference/codes.md b/docs/reference/codes.md index 8430487..d7f5557 100644 --- a/docs/reference/codes.md +++ b/docs/reference/codes.md @@ -34,7 +34,7 @@ Every warning and error the kit reports, one section per code. The kit reports e ### MSKITPKG005 -`MSKIT_PKG005` — no icon. Set `PackageIconPath` to a 128×128 PNG; the owner layer sets one for every package. +`MSKIT_PKG005` — no icon. Set `PackageIconPath` to a 128×128 PNG or JPEG, packed as `icon`; the owner layer sets one for every package. ### MSKITPKG006 @@ -74,7 +74,7 @@ Every warning and error the kit reports, one section per code. The kit reports e ### MSKITPKG015 -`MSKIT_PKG015` — the icon is not a PNG of `MSKit_PackageIconSize` × `MSKit_PackageIconSize` pixels (128). +`MSKIT_PKG015` — the icon is not a PNG or JPEG of `MSKit_PackageIconSize` × `MSKit_PackageIconSize` pixels (128); the size is checked on both formats. ### MSKITPKG016 diff --git a/docs/reference/properties.md b/docs/reference/properties.md index 7453fbb..6fe2a8b 100644 --- a/docs/reference/properties.md +++ b/docs/reference/properties.md @@ -93,7 +93,7 @@ Every property and item the kit sets or reads, grouped by topic. **Set** = a val | Name | Kind | Default | Meaning | | --- | --- | --- | --- | | `IsPackable` | set | `True` (owner layer); `False` for test projects and code fixes | | -| `PackageIconPath` | set | `.toolkit/res/package.icon.png` (owner layer) | The icon, packed as `icon.png` | +| `PackageIconPath` | set | `.toolkit/res/package.icon.png` (owner layer) | The icon (PNG or JPEG), packed as `icon` | | `MSKit_PackageIconSourcePath` | out | | The icon file the checks read | | `MSKit_PackageReadmeSourcePath` | set | `package.readme.md`, else `README.md` next to the csproj | The README packed as `readme.md` | | `MSKit_PackageValidationBaselineVersion` | set | empty | The release package validation compares against | diff --git a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets index 563bc1e..fa84441 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets @@ -29,7 +29,7 @@ - + @@ -60,7 +60,7 @@ Text="Package '$(PackageId)' has no PackageTags. Add a few search terms that are not already in the package id." Rule="$(_MSKit_PkgRules)#tags" /> <_MSKit_PkgFinding Include="MSKIT_PKG005" Condition="'$(PackageIcon)'==''" - Text="Package '$(PackageId)' has no icon. Set PackageIconPath to a 128x128 PNG (the company layer of the kit sets one for every package)." + Text="Package '$(PackageId)' has no icon. Set PackageIconPath to a 128x128 PNG or JPEG (the company layer of the kit sets one for every package)." Rule="$(_MSKit_PkgRules)#icon" /> <_MSKit_PkgFinding Include="MSKIT_PKG006" Condition="'$(PackageLicenseExpression)'=='' AND '$(PackageLicenseFile)'==''" Text="Package '$(PackageId)' has no licence. Without one nobody may use it. Set PackageLicenseExpression to an SPDX id (for example MIT)." @@ -89,8 +89,8 @@ <_MSKit_PkgFinding Include="MSKIT_PKG014" Condition="'$(_MSKit_RepoUrlFinal)'=='' OR '$(PackageProjectUrl)'==''" Text="Package '$(PackageId)' has no repository or project URL. Set RepositoryUrl, or build from a git clone whose origin remote Source Link can read." Rule="$(_MSKit_PkgRules)#repository-type-and-url" /> - <_MSKit_PkgFinding Include="MSKIT_PKG015" Condition="'$(MSKit_PackageIconSourcePath)'!='' AND Exists('$(MSKit_PackageIconSourcePath)') AND ('$(_MSKit_IconIsPng)'!='True' OR '$(_MSKit_IconWidth)'!='$(MSKit_PackageIconSize)' OR '$(_MSKit_IconHeight)'!='$(MSKit_PackageIconSize)')" - Text="Package '$(PackageId)' icon is not a $(MSKit_PackageIconSize)x$(MSKit_PackageIconSize) PNG (PNG: $(_MSKit_IconIsPng), size: $(_MSKit_IconWidth)x$(_MSKit_IconHeight))." + <_MSKit_PkgFinding Include="MSKIT_PKG015" Condition="'$(MSKit_PackageIconSourcePath)'!='' AND Exists('$(MSKit_PackageIconSourcePath)') AND (('$(_MSKit_IconFormat)'!='PNG' AND '$(_MSKit_IconFormat)'!='JPEG') OR '$(_MSKit_IconWidth)'!='$(MSKit_PackageIconSize)' OR '$(_MSKit_IconHeight)'!='$(MSKit_PackageIconSize)')" + Text="Package '$(PackageId)' icon is not a $(MSKit_PackageIconSize)x$(MSKit_PackageIconSize) PNG or JPEG (format: $(_MSKit_IconFormat), size: $(_MSKit_IconWidth)x$(_MSKit_IconHeight))." Rule="$(_MSKit_PkgRules)#icon" /> <_MSKit_PkgFinding Include="MSKIT_PKG016" Condition="'$(PackageReleaseNotes)'==''" Text="Package '$(PackageId)' has no PackageReleaseNotes. Set them, or a link to the changelog (the kit links the releases page by default on GitHub, and on any host the generated readme knows with MSKit_PackageReadmeFrom)." diff --git a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets index 9b2c60d..b299d47 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets @@ -51,9 +51,9 @@ + packed as icon. (icon.png, icon.jpg) unless the project sets PackageIcon. --> - icon.png + icon$([System.IO.Path]::GetExtension('$(PackageIconPath)').ToLowerInvariant()) <_MSKit_PacksIcon>True @@ -63,7 +63,7 @@ - + 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 d0d5ddf..0ef49d8 100644 --- a/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt +++ b/kit/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt @@ -31,7 +31,7 @@ public sealed class MSKit_PackageAssets : Task [Output] public string HtmlTags { get; set; } [Output] public string Alerts { get; set; } [Output] public bool HasMermaid { get; set; } - [Output] public bool IconIsPng { get; set; } + [Output] public string IconFormat { get; set; } [Output] public int IconWidth { get; set; } [Output] public int IconHeight { get; set; } @@ -96,16 +96,18 @@ public sealed class MSKit_PackageAssets : Task if (!string.IsNullOrEmpty(IconPath) && File.Exists(IconPath)) { - var header = new byte[24]; - using (var stream = File.OpenRead(IconPath)) + var icon = File.ReadAllBytes(IconPath); + IconFormat = "unknown"; + if (icon.Length >= 24 && icon[0] == 0x89 && icon[1] == 0x50 && icon[2] == 0x4E && icon[3] == 0x47) { - var read = stream.Read(header, 0, header.Length); - IconIsPng = read == 24 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; + IconFormat = "PNG"; + IconWidth = BigEndian(icon, 16, 4); + IconHeight = BigEndian(icon, 20, 4); } - if (IconIsPng) + else if (icon.Length >= 4 && icon[0] == 0xFF && icon[1] == 0xD8) { - IconWidth = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; - IconHeight = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + IconFormat = "JPEG"; + ReadJpegSize(icon); } } @@ -116,6 +118,31 @@ public sealed class MSKit_PackageAssets : Task Alerts = string.Join(", ", alerts.Distinct()); } + private static int BigEndian(byte[] bytes, int offset, int count) + { + var value = 0; + for (var i = 0; i < count; i++) value = (value << 8) | bytes[offset + i]; + return value; + } + + // Walks the JPEG segments to the first start-of-frame marker (SOF0..SOF15 except DHT, JPG, DAC). + private void ReadJpegSize(byte[] jpeg) + { + var i = 2; + while (i + 8 < jpeg.Length && jpeg[i] == 0xFF) + { + var marker = jpeg[i + 1]; + if (marker == 0xFF) { i++; continue; } + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) + { + IconHeight = BigEndian(jpeg, i + 5, 2); + IconWidth = BigEndian(jpeg, i + 7, 2); + return; + } + i += 2 + BigEndian(jpeg, i + 2, 2); + } + } + public string OutputPath { get; set; } public string FindingsBase { get; set; } public string ReleasesUrlPath { get; set; } diff --git a/tests/fixtures/PackageIcon/.gitignore b/tests/fixtures/PackageIcon/.gitignore new file mode 100644 index 0000000..428d735 --- /dev/null +++ b/tests/fixtures/PackageIcon/.gitignore @@ -0,0 +1 @@ +.toolkit/ diff --git a/tests/fixtures/PackageIcon/Directory.Build.props b/tests/fixtures/PackageIcon/Directory.Build.props new file mode 100644 index 0000000..ec7baa2 --- /dev/null +++ b/tests/fixtures/PackageIcon/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net8.0 + A fixture package that checks which icon is packed, and under which name. + fixture icon + $(MSBuildThisFileDirectory)Icons/$(FixtureIcon) + + + + + diff --git a/tests/fixtures/PackageIcon/Directory.Build.targets b/tests/fixtures/PackageIcon/Directory.Build.targets new file mode 100644 index 0000000..07200a8 --- /dev/null +++ b/tests/fixtures/PackageIcon/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/PackageIcon/Icons/ICON.PNG b/tests/fixtures/PackageIcon/Icons/ICON.PNG new file mode 100644 index 0000000..e890a29 Binary files /dev/null and b/tests/fixtures/PackageIcon/Icons/ICON.PNG differ diff --git a/tests/fixtures/PackageIcon/Icons/Icons.csproj b/tests/fixtures/PackageIcon/Icons/Icons.csproj new file mode 100644 index 0000000..49a935d --- /dev/null +++ b/tests/fixtures/PackageIcon/Icons/Icons.csproj @@ -0,0 +1,8 @@ + + + + + DragoAnt.Fixture.Icons + + + diff --git a/tests/fixtures/PackageIcon/Icons/Placeholder.cs b/tests/fixtures/PackageIcon/Icons/Placeholder.cs new file mode 100644 index 0000000..07b0a33 --- /dev/null +++ b/tests/fixtures/PackageIcon/Icons/Placeholder.cs @@ -0,0 +1,4 @@ +namespace Icons; + +/// Placeholder. +public static class Placeholder; diff --git a/tests/fixtures/PackageIcon/Icons/icon.jpg b/tests/fixtures/PackageIcon/Icons/icon.jpg new file mode 100644 index 0000000..ec76552 Binary files /dev/null and b/tests/fixtures/PackageIcon/Icons/icon.jpg differ diff --git a/tests/fixtures/PackageIcon/Icons/small.jpg b/tests/fixtures/PackageIcon/Icons/small.jpg new file mode 100644 index 0000000..c096e1a Binary files /dev/null and b/tests/fixtures/PackageIcon/Icons/small.jpg differ diff --git a/tests/fixtures/PackageIcon/Own/Own.csproj b/tests/fixtures/PackageIcon/Own/Own.csproj new file mode 100644 index 0000000..7975093 --- /dev/null +++ b/tests/fixtures/PackageIcon/Own/Own.csproj @@ -0,0 +1,13 @@ + + + + + DragoAnt.Fixture.OwnIcon + logo.png + + + + + + + diff --git a/tests/fixtures/PackageIcon/Own/Placeholder.cs b/tests/fixtures/PackageIcon/Own/Placeholder.cs new file mode 100644 index 0000000..28196b4 --- /dev/null +++ b/tests/fixtures/PackageIcon/Own/Placeholder.cs @@ -0,0 +1,4 @@ +namespace Own; + +/// Placeholder. +public static class Placeholder; diff --git a/tests/fixtures/PackageIcon/Own/logo.png b/tests/fixtures/PackageIcon/Own/logo.png new file mode 100644 index 0000000..e890a29 Binary files /dev/null and b/tests/fixtures/PackageIcon/Own/logo.png differ diff --git a/tests/package-icon.sh b/tests/package-icon.sh new file mode 100644 index 0000000..0a42146 --- /dev/null +++ b/tests/package-icon.sh @@ -0,0 +1,60 @@ +# Package icon: packed under icon, and a project's own PackageIcon is kept. +# Sourced by tests/run.sh: uses its pass, bad, $out, $here, $clean_env and $icon_fixture. + +rm -rf "$icon_fixture"/*/bin "$icon_fixture"/*/obj + +ic_pack() { + name="$1"; proj="$2"; shift 2 + rm -rf "$out/icon-$name" + : > "$out/icon-$name.nuspec"; : > "$out/icon-$name.files" + if $clean_env dotnet pack "$icon_fixture/$proj/$proj.csproj" -c Release -nologo -o "$out/icon-$name" \ + -p:MSKit_PackageChecksAsErrors=False "$@" > "$out/icon-$name.log" 2>&1; then + pass "icon $name: pack succeeds" + nupkg=$(ls "$out/icon-$name"/*.nupkg | grep -v '\.snupkg$' | head -n 1) + unzip -p "$nupkg" '*.nuspec' | tr -d '\r' > "$out/icon-$name.nuspec" + unzip -l "$nupkg" | awk 'NR>3 {print $4}' | grep -v '^$' > "$out/icon-$name.files" + else + bad "icon $name: pack failed (see $out/icon-$name.log)"; tail -n 20 "$out/icon-$name.log" + fi +} +ic_icon() { + actual=$(sed -n 's:.*\(.*\).*:\1:p' "$out/icon-$1.nuspec") + [ "$actual" = "$2" ] && pass "icon $1: nuspec $actual" || bad "icon $1: nuspec expected '$2', got '$actual'" +} +ic_entry() { grep -qx -- "$2" "$out/icon-$1.files" && pass "icon $1: nupkg contains $2" || bad "icon $1: nupkg lacks $2"; } +ic_no_entry() { grep -qx -- "$2" "$out/icon-$1.files" && bad "icon $1: nupkg still contains $2" || pass "icon $1: nupkg has no $2"; } +ic_warns() { grep -q "warning $2" "$out/icon-$1.log" && pass "icon $1 warns $2" || bad "icon $1 does not warn $2 (see $out/icon-$1.log)"; } +ic_quiet() { grep -q "$2" "$out/icon-$1.log" && bad "icon $1 reports $2 (see $out/icon-$1.log)" || pass "icon $1 does not report $2"; } + +# The kit's PNG icon. +ic_pack default Icons +ic_icon default icon.png +ic_entry default icon.png +ic_quiet default MSKIT_PKG015 + +# A JPEG PackageIconPath keeps its extension. +ic_pack jpg Icons -p:FixtureIcon=icon.jpg +ic_icon jpg icon.jpg +ic_entry jpg icon.jpg +ic_no_entry jpg icon.png +ic_quiet jpg NU5046 +ic_quiet jpg MSKIT_PKG015 + +# A JPEG of the wrong size is still reported. +ic_pack jpg-small Icons -p:FixtureIcon=small.jpg +ic_icon jpg-small icon.jpg +ic_warns jpg-small MSKIT_PKG015 + +# An upper-case extension is lowercased. +ic_pack upper Icons -p:FixtureIcon=ICON.PNG +ic_icon upper icon.png +ic_entry upper icon.png +ic_no_entry upper ICON.PNG +ic_no_entry upper icon.PNG + +# A project that sets PackageIcon keeps it; the kit's icon is not added. +ic_pack own Own +ic_icon own logo.png +ic_entry own logo.png +ic_no_entry own icon.png +ic_quiet own MSKIT_PKG015 diff --git a/tests/run.sh b/tests/run.sh index a979e1a..cf521c9 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -10,6 +10,7 @@ sample="$here/samples/MinimalLibrary" fixtures="$here/tests/fixtures/PackageChecks" readme_fixture="$here/tests/fixtures/PackageReadme" tfm_fixture="$here/tests/fixtures/TfmConstants" +icon_fixture="$here/tests/fixtures/PackageIcon" out="$here/dist/selftest" run_tests=1 [ "${1:-}" = "--skip-tests" ] && run_tests=0 @@ -19,7 +20,7 @@ failures=0 pass() { echo "PASS $*"; } bad() { echo "FAIL $*"; failures=$((failures+1)); } -for root in "$sample" "$fixtures" "$readme_fixture" "$tfm_fixture"; do +for root in "$sample" "$fixtures" "$readme_fixture" "$tfm_fixture" "$icon_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" @@ -109,6 +110,7 @@ grep -q "MSKIT_PKG" "$out/checks-skip.log" && bad "MSKit_SkipPackageChecks=All d . "$here/tests/package-readme.sh" . "$here/tests/tfm-constants.sh" +. "$here/tests/package-icon.sh" . "$here/tests/docs.sh" echo