Repository navigation
Expand file tree
/
Copy pathDirectory.Build.props
More file actions
203 lines (179 loc) · 15.9 KB
/
Copy pathDirectory.Build.props
File metadata and controls
203 lines (179 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
<Project>
<!-- ════════════════════════════════════════════════════════════════════════════
FS-GG ORG-SHARED MSBuild PROPERTIES
Source of truth: FS-GG/.github → dist/dotnet/Directory.Build.props
Distributed by scripts/sync-build-config.sh. DO NOT EDIT in a consumer repo —
edits are overwritten on the next sync and fail the drift check (check mode).
Repo-specific properties (TargetFramework, package metadata, F# warning
promotions, fsdocs, …) go in Directory.Build.local.props, imported after the
org defaults so a repo may override any of them (last definition wins). The one
block deliberately placed AFTER that import is the api-breaking-change-gate: it
must READ the adopter's $(FsggApiGate), which is set in local.props (H1 fix,
.github#125 — see its note below).
Contract: lockfile-restore-enforcement + shared-build-config (registry/dependencies.yml).
════════════════════════════════════════════════════════════════════════════ -->
<PropertyGroup Label="Determinism">
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Label="Central Package Management">
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<!-- lock-hash determinism (ADR-0032, .github#473/#471): FSharp.Core resolves from ONE source, so a
regenerated packages.lock.json is byte-identical on any machine.
THE DEFECT. The F# SDK injects its OWN bundled copy of FSharp.Core as a restore source:
Microsoft.FSharp.NetSdk.targets appends $(_FSharpCoreLibraryPacksFolder) —
…/sdk/<ver>/FSharp/library-packs — to RestoreAdditionalProjectSources, and pins
FSCorePackageVersion to exactly the version this org references (10.1.301). That copy is a
DIFFERENT .nupkg than nuget.org serves at the same id+version (3,051,664 B vs 3,066,660 B).
Nothing was ever re-published and no feed lied — there have always been two packages. A lock
file's `contentHash` is therefore a function of WHICH SOURCE served the package, and a local
folder source BEATS nuget.org when both are offered. So CI (no packageSourceMapping) resolved
library-packs and committed `FwQFuqOA…`, while a dev box whose mapping does not admit
library-packs resolved nuget.org and produced `excLf2zM…`. Same id, same version, two hashes,
and whichever machine ran the restore last won the `git diff`.
WHY IT HAS TO BE FIXED HERE, in MSBuild. The injection arrives via
RestoreAdditionalProjectSources, NOT as a configured NuGet source, so none of the usual
hermeticity levers touch it: `<clear/>` in nuget.config does not remove it, a fresh
NUGET_PACKAGES does not bypass it, and clearing the NuGet http-cache does not either. A
perfectly COLD restore still resolves the SDK's copy — cold is not hermetic (ADR-0032 §5 still
stands on its own merits; it just never closed this hole).
(Note for editors: XML comments cannot contain a double hyphen, so CLI flags are named without
their leading dashes throughout this comment.)
THE FIX. DisableImplicitLibraryPacksFolder is the SDK's own opt-out and it guards the
injection itself, so the folder is REMOVED from the source list rather than deprioritised —
with it set, a restore that can reach no other source fails NU1101 instead of quietly falling
back to the SDK copy. FSharp.Core then comes from nuget.org (also the signed copy) everywhere,
and `contentHash` becomes a function of (id, version) alone — no longer of the SDK patch level.
THAT CLAIM IS NOW EXECUTED, not just asserted (.github#473 step 3). tests/lockfile-cold/ leg 7
drives real restores against an empty feed, so the SDK's folder is the only thing that could
serve FSharp.Core, and checks three things: in the SDK's default state the folder DOES serve it
(the defect is real, so the test has a subject); with the property set the folder is gone and the
restore fails NU1101; and a probe that INHERITS THIS FILE gets the same exclusion.
That last leg is the one guarding the line below. The property is an SDK-INTERNAL condition in
Microsoft.FSharp.NetSdk.targets, not a documented MSBuild contract, so the SDK may rename it —
and this file is distributed, so it may also be edited into something that merely LOOKS set. A
grep could not tell the difference: it passes on a PropertyGroup wrapped in a false Condition,
on one overridden after the import, and on one commented out with the element still in the
prose. The fixture restores under the file instead, so anything that makes the property inert —
however cleverly — puts library-packs back in the source list and reds the leg. Nothing else in
the org would notice: the sync drift check only proves this file still MATCHES its copies, never
that it WORKS.
WHY THAT MATTERS, STATED HONESTLY (.github#536, corrected by #805). This comment used to say it
mattered "because global.json says rollForward: latestFeature", and was then corrected to say the
opposite — that four of the five consumers had NO global.json, so there was no floor to reason
from. BOTH sentences hard-coded a per-repo census, and a census is exactly the thing that rots:
the second one was false within weeks, once the consumers began adopting the pin (#805).
So state the part that does NOT rot. `rollForward: latestFeature` means a pin is a FLOOR, not a
lock: within the pinned major.minor it accepts the HIGHEST feature band and patch installed, so
10.0.301 is served by whatever 10.0.x the machine happens to have. (It is a floor, not a free
roll: a machine with only 11.x satisfies nothing and the build fails.) So a pin narrows WHICH
SDK restores without determining it — two machines honouring the same global.json can still
restore under different SDKs. An SDK-dependent contentHash is therefore a difference between
any two machines TODAY, not a risk that materialises the next time somebody bumps the pin.
Pinned or unpinned, SDK-independence is worth having, and this property is why.
Do not re-add a census in place of that argument. If you need to know who carries the pin, ASK
THE REPOS — the answer changes without touching this file, which is the whole problem. The
FILES note in FS-GG/.github's scripts/sync-build-config.sh carries a loop that measures it.
The SDK pin is distributed as a managed file (dist/dotnet/global.json, #536) but is NOT in
sync-build-config's FILES list. Presence in every consumer is NOT sufficient to add it: the
drift check compares CONTENT, so a consumer naming a different band than the canonical one goes
red the moment the name lands in that list — which is not hypothetical, it was true of three of
the five in July 2026. Whether it should EVER be managed is an open decision (.github#903);
leaving it unmanaged is a live option. Read the ordering note in FS-GG/.github's
scripts/sync-build-config.sh before you touch that list. (If you are reading this in a consumer
repo, that script is not here — this file is distributed; its source of truth is FS-GG/.github.)
ADOPTING THIS IS A ONE-TIME RE-PIN, AND THE ORDER IS LOAD-BEARING. Syncing this file flips the
source a repo restores from, so its committed `FwQFuqOA…` becomes `excLf2zM…`. Regenerate the
lock in the SAME PR that syncs this file (a cold `dotnet restore` with force-evaluate). Do NOT
re-pin a repo ahead of the sync: a lock re-pinned to nuget.org's hash, met by a CI that still
resolves library-packs, fails locked restore on a CORRECT lock file — which is precisely what
blocked #429's repair for a day (ADR-0032 §3). -->
<PropertyGroup Label="lock-hash determinism">
<DisableImplicitLibraryPacksFolder>true</DisableImplicitLibraryPacksFolder>
</PropertyGroup>
<PropertyGroup Label="lockfile-restore-enforcement">
<!-- Commit packages.lock.json and restore in locked mode in CI so any dependency-graph
drift (a stale or silently substituted FS.GG.UI.* version, or any transitive bump)
FAILS restore instead of resolving quietly. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- UNIFIED GATE (H3 / .github#19, ADR-0006): gate on GITHUB_ACTIONS, NOT on
ContinuousIntegrationBuild. Rationale: some repos (SDD) force CIB=true
unconditionally for determinism, so a CIB gate fails-closed on a fresh local
clone; GITHUB_ACTIONS is the real CI signal present in every repo's workflow.
`And Exists(...lockfile)` lets a first restore bootstrap the lockfile instead
of blocking, so a brand-new project is never wedged. -->
<RestoreLockedMode Condition="'$(GITHUB_ACTIONS)' == 'true' And Exists('$(MSBuildProjectDirectory)/packages.lock.json')">true</RestoreLockedMode>
<!-- Restore-phase NU16xx are not reliably promoted by TreatWarningsAsErrors, so promote
silent substitution (NU1603) and out-of-range (NU1608) explicitly. -->
<WarningsAsErrors>$(WarningsAsErrors);NU1603;NU1608</WarningsAsErrors>
</PropertyGroup>
<!-- Bootstrap fail-open guard (review low). The RestoreLockedMode condition above deliberately
`And Exists(...lockfile)` so a first restore can bootstrap a missing lockfile instead of
wedging a brand-new project. The cost: a repo that NEVER committed packages.lock.json restores
UNLOCKED in CI with no signal — the lockfile-restore-enforcement gate silently does nothing.
Surface it as a high-importance MESSAGE (not a Warning: an MSBuild <Warning> is promoted to an
error under `-warnaserror`/`MSBuildTreatWarningsAsErrors`, which would fail the very first
bootstrap restore and re-wedge the case this is meant to keep unblocked). Hooked to the single
restore target CollectPackageReferences so it prints once per restore; the repo closes the hole
by committing the generated lockfile. -->
<Target Name="FsggWarnMissingLockfileInCI"
BeforeTargets="CollectPackageReferences"
Condition="'$(GITHUB_ACTIONS)' == 'true' And '$(RestorePackagesWithLockFile)' == 'true' And !Exists('$(MSBuildProjectDirectory)/packages.lock.json')">
<Message Importance="high" Text="[lockfile-restore-enforcement] packages.lock.json is missing in CI (GITHUB_ACTIONS=true) — locked restore is NOT enforced for $(MSBuildProjectName); restore is running UNLOCKED (bootstrap fail-open). Commit the generated packages.lock.json to arm the gate." />
</Target>
<!-- Repo-specific overrides. This import sits AFTER the org defaults above (so a consumer can
override any of them — MSBuild property last-write-wins) and BEFORE the api-breaking-change-gate
below (so that gate can READ the adopter's $(FsggApiGate), which is set here in local.props).
H1 (.github#125): MSBuild evaluates PropertyGroup Conditions in document order, so a gate placed
ABOVE this import would test $(FsggApiGate) before local.props defines it and silently no-op the
EnablePackageValidation / WarningsNotAsErrors demotion — while its ItemGroup, evaluated in the
later item pass, would STILL inject the analyzer PackageReference, breaking an adopter's
TreatWarningsAsErrors build. Placing the whole gate after this import removes that asymmetry. -->
<Import Project="Directory.Build.local.props" Condition="Exists('$(MSBuildThisFileDirectory)Directory.Build.local.props')" />
<!-- api-breaking-change-gate (H3 / .github#20, epic #16 Pillar 5): catch accidental public-API
breaks on packable libraries (FS.GG.Contracts, FS.GG.UI.*) so a break forces a SemVer major,
which the registry version ranges then enforce.
OPT-IN and OFF by default (FsggApiGate unset). Turning it on adds a PackageReference, so a
repo enables it deliberately as an adoption step and regenerates its packages.lock.json then.
This keeps a plain sync of the shared config non-breaking under locked restore.
Evaluated AFTER the *.local.props import above so the Condition observes the adopter's
$(FsggApiGate) (H1 fix, .github#125).
F# NO-OP (M7 / .github#125; mechanism recorded on the apicompat-publicapi-gate registry row +
the #20 note): Microsoft.CodeAnalysis.PublicApiAnalyzers is a Roslyn C#/VB source analyzer and
is a SILENT NO-OP on the org's F# packables (FS.GG.Contracts, FS.GG.UI.*) — the RS00xx
diagnostics never fire there. On F# projects only the Package Validation / ApiCompat half
(EnablePackageValidation, below) yields API-break signal; the F# source-surface record stays the
constitution's committed .fsi signatures + surface/*.surface.txt baselines. Do NOT rely on the
RS00xx analyzer half to guard an F# public API — it bites on C#/VB packables only.
Stages (set FsggApiGate in Directory.Build.local.props):
unset / false → off.
advisory / true → on; public-API analyzer diagnostics stay WARNINGS even under the repo's
TreatWarningsAsErrors, so adoption never breaks a build. Add the baselines
(PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt) per packable project.
required → ratchet; the same diagnostics surface as build-breaking errors, and a repo
that also sets PackageValidationBaselineVersion gets ApiCompat at pack. -->
<PropertyGroup Label="api-breaking-change-gate"
Condition="'$(FsggApiGate)' != '' And '$(FsggApiGate)' != 'false'">
<!-- Package Validation = the SDK-integrated ApiCompat: compares the packed output against the
last published baseline at pack. Dormant until a repo sets PackageValidationBaselineVersion
(in *.local.props, to its last released version), so enabling it here is harmless. This is the
ONLY half of the gate that bites on the F# packables (see F# NO-OP above). -->
<EnablePackageValidation Condition="'$(IsPackable)' != 'false'">true</EnablePackageValidation>
<!-- Advisory keeps the public-API analyzer diagnostics non-breaking regardless of the repo's
TreatWarningsAsErrors; FsggApiGate=required drops this demotion so they fail the build.
RS0016/RS0017 symbol (not) in declared API; RS0022 constructor; RS0024/RS0025/RS0026/RS0027
shape/overload; RS0036/RS0037 nullability/annotation; RS0041 oblivious API. -->
<WarningsNotAsErrors Condition="'$(FsggApiGate)' != 'required'">$(WarningsNotAsErrors);RS0016;RS0017;RS0022;RS0024;RS0025;RS0026;RS0027;RS0036;RS0037;RS0041</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup Label="api-breaking-change-gate"
Condition="'$(FsggApiGate)' != '' And '$(FsggApiGate)' != 'false'">
<!-- Analyzer-only (PrivateAssets=all): never flows to a package's dependencies. Version is
centrally pinned in Directory.Packages.props. The analyzer tracks the declared API only when
both baseline files are present, so an opt-in repo that has not added them yet stays silent
(run the analyzer's code-fix to generate them). C#/VB only — a no-op on F# (see above). -->
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="all" />
<AdditionalFiles Include="PublicAPI.Shipped.txt" Condition="Exists('$(MSBuildProjectDirectory)/PublicAPI.Shipped.txt')" />
<AdditionalFiles Include="PublicAPI.Unshipped.txt" Condition="Exists('$(MSBuildProjectDirectory)/PublicAPI.Unshipped.txt')" />
</ItemGroup>
</Project>