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
89 changes: 89 additions & 0 deletions .github/workflows/build-and-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Builds, verifies, and publishes to GitHub Packages.
#
# Verification is the point rather than a formality. What this repository ships is configuration, and
# configuration fails silently: a rule that cannot report looks exactly like a rule being obeyed,
# because the build is quiet either way. Both scripts below exist to make that noisy, and they run on
# pull requests as well as on main, because a rule that stopped reporting should not be discovered
# after it has merged.
name: build and publish

on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:

# Only a version tag publishes. Pushes to main and pull requests build and verify and stop there,
# so merging is not the same act as releasing.
permissions:
contents: read
packages: write

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- uses: actions/checkout@v7

- uses: actions/setup-dotnet@v6
with:
# 10, not 8. An analyser cannot reference a newer Roslyn than the compiler running it, and
# UsingLayoutAnalyser is the rule set most likely to be affected: on SDK 8 it answers
# CS9057 - a warning - and its rules are then silently absent. This is also what consumers
# build with, and testing on what people use beats testing on the oldest thing that works.
dotnet-version: '10.x'

# On a tag the tag is the version, so nothing in the repository can disagree with what shipped.
# Off a tag this is unset and expands to nothing, leaving the csproj's own <Version> - which is
# then only ever a local default, never the thing that gets published.
- name: Take the version from the tag
if: github.ref_type == 'tag'
run: echo "VERSION_ARG=-p:Version=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"

# No NuGet.config and no secure file: every dependency is public on nuget.org. The Azure
# pipeline this replaced downloaded a secure NuGet.config it did not need.
- name: Build
run: dotnet build Nota.CodeAnalysis.sln --configuration Release $VERSION_ARG

- name: Verify the rules report
run: ./Nota.CodeAnalysis.Verification/verify.sh

- name: Verify source encoding
run: ./Nota.CodeAnalysis.Verification/verify-encoding.sh

# The two checks above run inside the solution and cannot see whether the package delivers
# anything. This one packs, installs into a throwaway project, and compiles a file that breaks
# a rule from each analyser. It is the only check that fails when a package installs cleanly
# and does nothing, which has happened twice.
- name: Verify the package delivers the rules
run: ./Nota.CodeAnalysis.Verification/verify-package.sh

- name: Pack
run: dotnet pack Nota.CodeAnalysis.sln --configuration Release --no-build --output artifacts $VERSION_ARG

# Kept even on a pull request, so a packaging mistake is visible without merging.
- name: Upload the package
uses: actions/upload-artifact@v7
with:
name: nupkg
path: artifacts/*.nupkg

# GITHUB_TOKEN rather than a personal access token: publishing to this repository's own package
# registry needs nothing else, which is the whole reason to be here rather than in Azure.
# --skip-duplicate so re-running a failed job is not itself an error; the registry keeps a
# version forever, and pushing the same one twice is worth ignoring rather than failing on.
- name: Publish to GitHub Packages
if: github.ref_type == 'tag'
run: >
dotnet nuget push artifacts/*.nupkg
--source https://nuget.pkg.github.com/Notalib/index.json
--api-key ${{ secrets.GITHUB_TOKEN }}
--skip-duplicate
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
.vs
obj
bin

# macOS Finder metadata.
.DS_Store

# Rider / IntelliJ project metadata.
.idea/

# Local pack output.
artifacts/
59 changes: 0 additions & 59 deletions DevOps/Azure-Pipelines/nuget.yml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Not a unit test project. It is a consumer of the rules, built on purpose against files that break
them, so that verify.sh can assert each rule actually reported. The product of this repository is
configuration, and configuration fails silently: dotnet_diagnostic.CS8019.severity sat here for
years asking for unused usings to be reported, and reported nothing. Nothing would have noticed.
-->
<PropertyGroup>
<!-- net10.0, so this stands in for what consumers actually build on. On net8.0 it would still
pass while proving less: SDK 8's Roslyn is too old to load UsingLayoutAnalyser at all. -->
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>

<!-- The rules must report, not stop the build - verify.sh reads the warnings. -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>

<!-- What build/Nota.CodeAnalysis.props gives a consumer. Kept in step with it by verify.sh, which
fails if a property here is missing from the props file. -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<GlobalAnalyzerConfigFiles Include="../Nota.CodeAnalysis/content/Nota.CodeAnalysis.globalconfig" />
</ItemGroup>

<!--
The encoding guard the package ships, imported the way a consumer gets it. NOTA0001 is a build
error rather than an analyser diagnostic, so it is the one rule here that cannot be proven by
reading a severity out of the globalconfig - it has to actually run.
-->
<Import Project="../Nota.CodeAnalysis/build/Nota.CodeAnalysis.targets" />

<!--
The samples break the rules deliberately, and some of those rules are error severity - IDE0008 is
- so compiling them would fail any ordinary build of the solution, including the one the pipeline
runs before it gets here. They are compiled only when verify.sh asks for them, which is the only
time anyone wants a project that does not build.
-->
<ItemGroup>
<Compile Remove="Samples/**" />
<Compile Include="Samples/**" Condition="'$(VerifyRules)' == 'true'" />
<None Include="Samples/**" Condition="'$(VerifyRules)' != 'true'" />
<None Include="verify.sh" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="18.7.23" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" />
<PackageReference Include="UsingLayoutAnalyser" Version="0.2.1" />
</ItemGroup>

</Project>
111 changes: 111 additions & 0 deletions Nota.CodeAnalysis.Verification/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Nota.CodeAnalysis.Verification

Checks that the rules this repository ships actually report. Nothing here is shipped or consumed:
`IsPackable` is false, it is in no package, and nothing depends on it.

## Why it exists

The product of this repository is configuration, and configuration fails silently. A rule that is
misspelled, superseded, or simply incapable of reporting looks exactly like a rule that is being
obeyed - the build is quiet either way.

That is not hypothetical. `dotnet_diagnostic.CS8019.severity = warning` asked for unused usings to be
reported, and reported nothing, for as long as anyone can tell: CS8019 is emitted hidden by the
compiler and a severity in config does not raise it. One consuming solution had forty-five unused
directives behind it. Every build was green.

So this project is a consumer, built on purpose against files that break the rules, and a script that
fails if any of them stayed quiet.

## Running it

```sh
./verify.sh # the rules report
./verify-encoding.sh # source files are valid UTF-8
./verify-package.sh # the rules survive being packaged
```

All three run in the pipeline, on pull requests as well as on `main`. All exit non-zero on failure
and name what went wrong.

## How it is put together

`Samples/` is excluded from compilation unless `VerifyRules` is set:

```xml
<Compile Remove="Samples/**" />
<Compile Include="Samples/**" Condition="'$(VerifyRules)' == 'true'" />
```

Some of the rules are error severity - `IDE0008` is - so compiling the samples would fail any
ordinary build of the solution, including the one the pipeline runs before it gets here. `verify.sh`
passes `-p:VerifyRules=true`; every other build gets an empty assembly.

The project also declares the same properties `build/Nota.CodeAnalysis.props` gives a consumer, and
`verify.sh` fails if the props file stops setting one of them. Otherwise this project could drift
into testing a configuration nobody actually receives - and `GenerateDocumentationFile` in particular
looks redundant and is not: without it `IDE0005` silently stops reporting.

## What `verify.sh` asserts

`Samples/Broken.cs` breaks each of these deliberately.

| Rule | What it catches |
|-----------|----------------------------------------------|
| `IDE0005` | an unused using - what CS8019 never did |
| `IDE0008` | `var` instead of an explicit type |
| `UA1000` | using directives out of order |
| `SA1208` | System usings not placed first |
| `SA1516` | no blank line after the System group |

## What `verify-encoding.sh` asserts

Every source file is valid UTF-8, or UTF-16 carrying a BOM.

This is the guard that let `SA1412` be switched off. SA1412 demanded a byte order mark, which was
never what anyone wanted, but it was the only thing standing between the build and a file saved as
Windows-1252 - and such a file compiles with no warning at all, putting U+FFFD replacement characters
straight into the assembly. No analyser can report that: by the time an analyser runs, the text has
already been decoded. It is also a property of `.resx` and `.json` files, which no analyser reads.

BOM-marked UTF-16 is accepted rather than flagged. svcutil and EF migrations emit it, the compiler
reads it correctly, and those files must keep their BOM - it is the only record of their encoding.

It cannot catch a wrong encoding that happens to produce valid UTF-8, the classic `“` mojibake,
which is indistinguishable from someone writing those characters on purpose.

## Adding a rule

Break it in `Samples/Broken.cs`, then add its id to `expected` in `verify.sh`.

**Watch it fail before you trust it.** Set the rule's severity to `none` in the globalconfig and run
`verify.sh`; it should fail naming that rule. A check nobody has seen fail is not a check - which is
the whole reason this project exists.

## What `verify-package.sh` asserts

That the rules survive packaging, which the other two cannot see. They run inside the solution, where
the globalconfig is imported directly and the analysers are referenced by the project itself - so a
rule can be reported here while no consumer receives it. That is not theoretical - it happened twice
while this project was being written, both times caught before merging:

- **UsingLayoutAnalyser was built against a newer Roslyn than the SDK running it.** The compiler
answered `CS9057`, a warning, and skipped the analyser. Green build, no using rules.
- **The threading analyser acquired `PrivateAssets` during a version bump**, which stops a reference
reaching consumers. `VSTHRD100` went on firing inside this solution while consumers got nothing.

So it packs, installs into a throwaway project from a local feed, and compiles a file that breaks one
rule per analyser - plus a deliberately mis-encoded file for `NOTA0001`, which proves
`build/Nota.CodeAnalysis.targets` was packed and imported. It fails on `CS9057` too, since that is a
warning nothing else would notice.

It packs under a throwaway version like `0.0.0-verify.20260802143000`. That is not cosmetic: NuGet
extracts a package once per version into the global cache, so re-packing `2.2.0` and installing
`2.2.0` gets whatever was extracted first, and the change under test never reaches the consumer. This
script passed cleanly against a regression it was written to catch until the version was made unique.

## What none of them cover

The scripts are POSIX `sh` and run on `ubuntu-latest`. On a Windows agent they would need porting.

`verify-package.sh` needs network on a cold cache, for the throwaway project's own dependencies.
17 changes: 17 additions & 0 deletions Nota.CodeAnalysis.Verification/Samples/Broken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Nota.Vendor;
using System;
using System.Xml;

namespace Nota.Verification;

/// <summary>Deliberately wrong. Every diagnostic here is asserted by verify.sh.</summary>
public static class Broken
{
/// <summary>Uses var, which IDE0008 forbids.</summary>
public static string Value()
{
var text = typeof(Thing).Name + Environment.NewLine;

return text;
}
}
8 changes: 8 additions & 0 deletions Nota.CodeAnalysis.Verification/Samples/Latin1Encoded.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Nota.Verification;

/// <summary>Saved as Windows-1252 on purpose. See verify.sh.</summary>
public static class Latin1Encoded
{
/// <summary>Danish text in the wrong encoding.</summary>
public const string Text = "���";
}
6 changes: 6 additions & 0 deletions Nota.CodeAnalysis.Verification/Samples/Vendor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Nota.Vendor;

/// <summary>Stub, so the first using resolves.</summary>
public class Thing
{
}
Loading