Skip to content

Commit 3266755

Browse files
petrsvihlikclaude
andauthored
build: actually enable NuGet package validation (props/targets evaluation-order bug) (#551)
* build: actually enable NuGet package validation EnablePackageValidation and the CS1591 waiver were conditioned on $(IsPackable) / $(PackageId) inside Directory.Build.props, which is imported before project bodies set those properties — both conditions always saw empty values, so package validation never ran for any project and CS1591 was waived everywhere. The same evaluation-order rule was already documented in Directory.Build.targets for PackageReadmeFile; both conditions now live there. An unset IsPackable counts as packable because NuGet's own IsPackable=true default is applied by the pack targets, which import even later. PackageValidationBaselineVersion stays in Directory.Build.props so the bump-baseline workflow's sed keeps finding it. Fallout from turning the gates on: - CS1591 enforcement surfaced 16 undocumented public constants in WopiTelemetry — documented. - Baseline validation against 9.0.0 flagged the two intentional AddActivities interface additions on WopiHost.Abstractions — suppressed via the generated CompatibilitySuppressions.xml. Verified with a release-style pack (dotnet pack -p:Version=9.99.0): validation downloads the 9.0.0 baselines and passes for all nine published packages. https://claude.ai/code/session_019MeqK2SERqN3JAQKAKaRKR * build: drop tombstone comments pointing from props to targets The CS1591-waiver note and the EnablePackageValidation note in Directory.Build.props described code that no longer lives there; the conditions and their evaluation-order rationale are documented in Directory.Build.targets where they actually run. The baseline-version comment stays, since it documents the property directly below it. * ci(bump-baseline): retire CompatibilitySuppressions.xml when the baseline advances A suppression records an intentional break against the previous baseline. Once bump-baseline moves the baseline to the just-released version those breaks are part of the baseline and the suppressions match nothing, so the auto-bump PR now git-rm's them in the same change. The glob matches at any depth, so it works for a single shared file or per-project files. * ci(api-compat): make the PR check suppression-aware and blocking Feed each project's CompatibilitySuppressions.xml (the same default path the SDK's pack-time validator reads at release) to the apicompat global tool via --suppression-file, and fail the job on any break that isn't suppressed. Acknowledged breaks are masked, so the report shows only what the PR itself changes; a deliberate break is acknowledged by committing the regenerated suppression file. The sticky comment now posts on always() so the report is visible even when the check fails. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8af14e5 commit 3266755

6 files changed

Lines changed: 117 additions & 13 deletions

File tree

.github/workflows/bump-baseline.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ jobs:
112112
113113
sed -i "s|<PackageValidationBaselineVersion>[^<]*</PackageValidationBaselineVersion>|<PackageValidationBaselineVersion>${NEW}</PackageValidationBaselineVersion>|g" "$FILE"
114114
115+
- name: Retire suppressions absorbed into the new baseline
116+
# A CompatibilitySuppressions.xml entry records an intentional API break against the
117+
# *previous* baseline. Once the baseline advances to the just-released version those
118+
# breaks are part of the baseline, so the suppressions match nothing and are dead
119+
# weight. Drop them in the same bump PR so each release cycle starts clean and the
120+
# files only ever carry breaks introduced *after* the last release. The glob matches
121+
# at any depth, so it works whether suppressions live in one file or one per project.
122+
shell: bash
123+
run: |
124+
set -euo pipefail
125+
mapfile -t files < <(git ls-files '*CompatibilitySuppressions.xml')
126+
if [[ ${#files[@]} -eq 0 ]]; then
127+
echo "No CompatibilitySuppressions.xml files to retire."
128+
else
129+
printf 'Retiring: %s\n' "${files[@]}"
130+
git rm --quiet "${files[@]}"
131+
fi
132+
115133
- name: Open PR with the bump
116134
# peter-evans/create-pull-request is intentionally idempotent: if Directory.Build.props
117135
# has no diff (because CURRENT was already at NEW, or because the backward-bump guard
@@ -131,6 +149,8 @@ jobs:
131149
${{ github.event_name == 'release' && format('Triggered by the [{0}]({1}) release.', github.event.release.tag_name, github.event.release.html_url) || 'Triggered manually via workflow_dispatch.' }}
132150
133151
Keeps the SDK's pack-time package validator comparing the current public surface against the version that's actually published to NuGet.org.
152+
153+
Any `CompatibilitySuppressions.xml` left over from the previous cycle is retired here too — those entries are now part of the baseline and would otherwise linger as dead weight.
134154
labels: |
135155
chore
136156
automated

.github/workflows/pull_request.yml

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,11 @@ jobs:
152152
echo "<!-- apicompat-report -->"
153153
echo "## API Compatibility Report"
154154
echo ""
155-
echo "Compared this PR's packed assemblies against the latest stable release on [NuGet.org](https://www.nuget.org/packages?q=WopiHost) for each library. This check is **informational only** — intentional breaks at major version bumps are expected."
155+
echo "Compared this PR's packed assemblies against the latest stable release on [NuGet.org](https://www.nuget.org/packages?q=WopiHost) for each library, honoring each project's \`CompatibilitySuppressions.xml\`. **This check fails on any public-API break not recorded as intentional** — already-suppressed breaks are masked, so what surfaces below is what *this PR* changes. If a break is deliberate, regenerate and commit the suppressions:"
156+
echo ""
157+
echo '```'
158+
echo "dotnet pack -c Release -p:ApiCompatGenerateSuppressionFile=true"
159+
echo '```'
156160
echo ""
157161
} > "$REPORT_FILE"
158162
@@ -187,21 +191,31 @@ jobs:
187191
"https://api.nuget.org/v3-flatcontainer/${pkg_lower}/${baseline_version}/${pkg_lower}.${baseline_version}.nupkg" \
188192
-o "$baseline_nupkg"
189193
194+
# Feed the project's suppression file (default path; the same one the SDK's pack-time
195+
# validator reads at release) so acknowledged breaks are masked and only new ones show.
196+
# --permit-unnecessary-suppressions keeps a stale entry (one whose break is already in
197+
# the baseline, before the post-release cleanup runs) from failing the check.
198+
supp_args=()
199+
supp_file="src/${pkg}/CompatibilitySuppressions.xml"
200+
[[ -f "$supp_file" ]] && supp_args+=(--suppression-file "$supp_file")
201+
190202
# Run apicompat. Note: <package> is positional, --baseline-package is named, --run-api-compat is required.
191203
# The tool installs as 'apicompat' (not 'dotnet apicompat') and exits 0 on both clean and break
192204
# cases; detect findings via output text.
193205
set +e
194206
output="$(apicompat package "$new_nupkg" \
195207
--baseline-package "$baseline_nupkg" \
196208
--run-api-compat \
209+
--permit-unnecessary-suppressions \
210+
"${supp_args[@]}" \
197211
--verbosity Normal 2>&1)"
198212
set -e
199213
200214
if grep -Eq '^(CP|PKV)[0-9]+:' <<< "$output"; then
201215
ANY_DIFFS=1
202216
{
203-
echo "### :warning: ${pkg} vs \`${baseline_version}\`"
204-
echo "<details><summary>API differences found — click to expand</summary>"
217+
echo "### :x: ${pkg} vs \`${baseline_version}\`"
218+
echo "<details open><summary>Unsuppressed public-API break(s) — revert or suppress</summary>"
205219
echo ""
206220
echo '```'
207221
echo "${output}"
@@ -222,15 +236,23 @@ jobs:
222236
{
223237
echo "---"
224238
if [[ $ANY_DIFFS -eq 0 ]]; then
225-
echo "_No API differences detected across any package._"
239+
echo "_No unsuppressed public-API breaks across any package._"
226240
else
227-
echo "_Diagnostic IDs starting with \`CP\` are assembly-level diffs; \`PKV\` are package-shape diffs. See the [ApiCompat docs](https://learn.microsoft.com/dotnet/fundamentals/apicompat/diagnostic-ids) for details._"
241+
echo "_If these breaks are intentional, run \`dotnet pack -c Release -p:ApiCompatGenerateSuppressionFile=true\` and commit the regenerated \`CompatibilitySuppressions.xml\`. \`CP\` IDs are assembly-level diffs; \`PKV\` are package-shape diffs ([docs](https://learn.microsoft.com/dotnet/fundamentals/apicompat/diagnostic-ids))._"
228242
fi
229243
} >> "$REPORT_FILE"
230244
231245
# Surface in the job log too.
232246
cat "$REPORT_FILE"
247+
248+
# Fail the check on any unsuppressed break (the report is posted by the next step,
249+
# which runs on always()). A deliberate break is acknowledged via its suppression file.
250+
if [[ $ANY_DIFFS -ne 0 ]]; then
251+
echo "::error::Unsuppressed public-API break(s) detected — see the API Compatibility Report comment."
252+
exit 1
253+
fi
233254
- name: Post sticky PR comment
255+
if: always()
234256
uses: marocchino/sticky-pull-request-comment@v3
235257
with:
236258
header: apicompat-report

Directory.Build.props

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,6 @@
5050
<IncludeCobalt Condition="'$(IncludeCobalt)' == ''">false</IncludeCobalt>
5151
</PropertyGroup>
5252

53-
<!-- Non-packable projects (samples, infra, tests): silence CS1591 since they don't
54-
ship to consumers and don't need XML docs on every public member. -->
55-
<PropertyGroup Condition="$(IsPackable) != 'true' OR $(PackageId) == ''">
56-
<NoWarn>$(NoWarn);CS1591</NoWarn>
57-
</PropertyGroup>
58-
5953
<!-- Package Validation Configuration for NuGet packages.
6054
6155
PackageValidationBaselineVersion is the last NuGet-published version we validate the
@@ -67,8 +61,7 @@
6761
after the release then leaves the baseline at the wrong version with no auto-bump PR
6862
queued. If you genuinely need to override the baseline (e.g. to skip a buggy release),
6963
do it in its own dedicated PR, not bundled with API changes. -->
70-
<PropertyGroup Condition="$(IsPackable) == 'true' AND $(PackageId) != ''">
71-
<EnablePackageValidation>true</EnablePackageValidation>
64+
<PropertyGroup>
7265
<PackageValidationBaselineVersion>9.0.0</PackageValidationBaselineVersion>
7366
</PropertyGroup>
7467

Directory.Build.targets

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,26 @@
55
<PropertyGroup Condition="'$(PackageId)' != ''">
66
<PackageReadmeFile>README.md</PackageReadmeFile>
77
</PropertyGroup>
8+
9+
<!-- Same evaluation-order rule as PackageReadmeFile above: these conditions read
10+
$(IsPackable) / $(PackageId), which projects set in their bodies (or directory-level
11+
props imported below the root), so they only see the final values here. An UNSET
12+
IsPackable counts as packable — NuGet's own `IsPackable=true` default is applied by the
13+
pack targets, which import after this file, so it can't be observed here either. -->
14+
15+
<!-- Missing-XML-doc warnings are waived for everything that is not a published package
16+
(tests, samples, infra, tools); the packaged libraries keep CS1591 enforced via
17+
TreatWarningsAsErrors. -->
18+
<PropertyGroup Condition="$(IsPackable) == 'false' OR $(PackageId) == ''">
19+
<NoWarn>$(NoWarn);CS1591</NoWarn>
20+
</PropertyGroup>
21+
22+
<!-- Pack-time API validation for published packages, comparing against
23+
$(PackageValidationBaselineVersion) from Directory.Build.props (auto-bumped after each
24+
stable release by .github/workflows/bump-baseline.yml). The empty-value guard respects
25+
per-project opt-outs: a package with no prior release on NuGet.org sets
26+
EnablePackageValidation=false in its csproj because there is no baseline to download. -->
27+
<PropertyGroup Condition="$(EnablePackageValidation) == '' AND $(IsPackable) != 'false' AND $(PackageId) != ''">
28+
<EnablePackageValidation>true</EnablePackageValidation>
29+
</PropertyGroup>
830
</Project>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
3+
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
4+
<Suppression>
5+
<DiagnosticId>CP0006</DiagnosticId>
6+
<Target>M:WopiHost.Abstractions.IWopiHostExtensions.OnAddActivitiesAsync(WopiHost.Abstractions.WopiAddActivitiesContext,System.Threading.CancellationToken)</Target>
7+
<Left>lib/net10.0/WopiHost.Abstractions.dll</Left>
8+
<Right>lib/net10.0/WopiHost.Abstractions.dll</Right>
9+
<IsBaselineSuppression>true</IsBaselineSuppression>
10+
</Suppression>
11+
<Suppression>
12+
<DiagnosticId>CP0006</DiagnosticId>
13+
<Target>P:WopiHost.Abstractions.IWopiHostCapabilities.SupportsAddActivities</Target>
14+
<Left>lib/net10.0/WopiHost.Abstractions.dll</Left>
15+
<Right>lib/net10.0/WopiHost.Abstractions.dll</Right>
16+
<IsBaselineSuppression>true</IsBaselineSuppression>
17+
</Suppression>
18+
</Suppressions>

src/WopiHost.Core/Infrastructure/WopiTelemetry.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,30 +37,59 @@ public static class WopiTelemetry
3737
/// <summary>Tag keys shared across spans, log scopes, and metrics.</summary>
3838
public static class Tags
3939
{
40+
/// <summary>WOPI operation name (e.g. <c>CheckFileInfo</c>, <c>Lock</c>).</summary>
4041
public const string Operation = "wopi.operation";
42+
43+
/// <summary>File identifier of the targeted resource.</summary>
4144
public const string FileId = "wopi.file_id";
45+
46+
/// <summary>Container identifier of the targeted resource.</summary>
4247
public const string ContainerId = "wopi.container_id";
48+
49+
/// <summary>Lock identifier involved in the operation.</summary>
4350
public const string LockId = "wopi.lock_id";
51+
52+
/// <summary>Operation outcome; values come from <see cref="Outcomes"/>.</summary>
4453
public const string Outcome = "wopi.outcome";
54+
55+
/// <summary>The request's <c>X-WOPI-Override</c> header value.</summary>
4556
public const string Override = "wopi.override";
57+
58+
/// <summary>Authenticated user id (OpenTelemetry's standard <c>enduser.id</c> key).</summary>
4659
public const string UserId = "enduser.id";
4760
}
4861

4962
/// <summary>Canonical strings for the <see cref="Tags.Outcome"/> dimension.</summary>
5063
public static class Outcomes
5164
{
65+
/// <summary>Operation completed successfully (2xx).</summary>
5266
public const string Success = "success";
67+
68+
/// <summary>Target resource not found (404).</summary>
5369
public const string NotFound = "not_found";
70+
71+
/// <summary>Generic conflict (409) other than a lock mismatch.</summary>
5472
public const string Conflict = "conflict";
73+
74+
/// <summary>Lock conflict (409 with <c>X-WOPI-Lock</c>).</summary>
5575
public const string LockMismatch = "lock_mismatch";
76+
77+
/// <summary>Malformed or invalid request (400).</summary>
5678
public const string BadRequest = "bad_request";
79+
80+
/// <summary>Precondition failed (412).</summary>
5781
public const string PreconditionFailed = "precondition_failed";
82+
83+
/// <summary>Operation not supported by this host (501).</summary>
5884
public const string NotImplemented = "not_implemented";
85+
86+
/// <summary>Request rejected by WOPI proof-key validation (500 per spec).</summary>
5987
public const string ProofValidationFailed = "proof_validation_failed";
6088

6189
/// <summary>Client disconnected / request aborted before completion. Not a server error.</summary>
6290
public const string Cancelled = "cancelled";
6391

92+
/// <summary>Unhandled server error (5xx).</summary>
6493
public const string Error = "error";
6594
}
6695

0 commit comments

Comments
 (0)