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
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"version": "1.4.0",
"commands": [
"csharpier"
],
Expand Down
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Purview.BuildSdk 1.3.0 - managed file, do not edit
# Purview.BuildSdk 2.0.0 - managed file, do not edit
#
# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version
# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is
Expand Down
113 changes: 112 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,32 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

# global.json pins the SDK this repository builds with, and every dotnet command resolves
# against it, so installing a different band fails the first command with "A compatible .NET
# SDK was not found". Resolve it here rather than repeating the version in two workflows.
# The fallback only applies if global.json ever stops pinning one.
- name: Resolve the .NET SDK version
id: resolve-sdk
shell: bash
run: |
set -euo pipefail

pinned=""
if [ -f global.json ]; then
pinned="$(jq -r '.sdk.version // empty' global.json)"
fi

if [ -n "$pinned" ]; then
echo "global.json pins $pinned; installing that."
echo "dotnet-version=$pinned" >> "$GITHUB_OUTPUT"
else
echo "global.json pins no sdk.version; using the default (10.0.x)."
echo "dotnet-version=10.0.x" >> "$GITHUB_OUTPUT"
fi

- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6
with:
dotnet-version: "10.0.x"
dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }}

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

Expand Down Expand Up @@ -71,3 +94,91 @@ jobs:
uses: ./actions/nuget-sources
with:
mode: remove

# NativeAOT is the only gate that catches reflection-based System.Text.Json: an AOT build throws on
# the first call instead of degrading, and neither the compiler nor an ordinary test run notices -
# release-explain --format=json passed every test while being dead in an AOT publish.
# `just publish-aot` is the same check locally; keep the two in step.
aot:
name: NativeAOT (${{ matrix.os }})
strategy:
# One platform failing must not hide the other two: a Windows-only linker or toolchain problem
# is exactly the kind of thing this matrix exists to distinguish.
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
# bash everywhere rather than branching on runner.os, so the steps below are identical on all
# three platforms. macOS and Windows runners both ship bash.
shell: bash
env:
# Used by the package source's %GITHUB_TOKEN% credential, so restores can read the feed.
GITHUB_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Resolve the .NET SDK version
id: resolve-sdk
shell: bash
# See the build job: global.json wins, and the fallback only applies if it pins nothing.
run: |
set -euo pipefail

pinned=""
if [ -f global.json ]; then
pinned="$(jq -r '.sdk.version // empty' global.json)"
fi

if [ -n "$pinned" ]; then
echo "global.json pins $pinned; installing that."
echo "dotnet-version=$pinned" >> "$GITHUB_OUTPUT"
else
echo "global.json pins no sdk.version; using the default (10.0.x)."
echo "dotnet-version=10.0.x" >> "$GITHUB_OUTPUT"
fi

- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6
with:
dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }}

# The MSBuild SDK itself comes from the organisation feed, so this job restores just like the
# build job does. Fork pull requests cannot read that feed and restore from NuGet.org instead.
- name: Configure NuGet sources
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
uses: ./actions/nuget-sources
with:
namespace: ${{ github.repository_owner }}
token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN || github.token }}
source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }}

# One framework because publish refuses a multi-targeted project (NETSDK1129), and
# --use-current-runtime so each runner compiles for its own architecture.
- name: Publish with NativeAOT
run: |
set -euo pipefail

dotnet publish src/src/Build/Build.csproj \
--configuration Release \
--output .tools/aot \
-p:PurviewTargetFrameworkSet=Latest \
-p:PublishAot=true \
--use-current-runtime

# release-explain exits 1 whenever the current ref is ineligible, which is a verdict rather than
# a failure. What is asserted is that the process emitted the JSON contract at all: under AOT a
# reflection-based serializer throws and stdout stays empty.
- name: Smoke-test the native executable
run: |
set -euo pipefail

.tools/aot/Purview.Build --version
json="$(.tools/aot/Purview.Build release-explain --format=json || true)"
printf '%s' "$json" | jq -e '.verdict'

- name: Remove temporary NuGet sources
if: always()
uses: ./actions/nuget-sources
with:
mode: remove
23 changes: 22 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,30 @@ jobs:
fetch-depth: 0
fetch-tags: true

# See ci.yml: global.json is the single source of truth for the SDK, and a run that installs
# a different band fails before the pipeline starts.
- name: Resolve the .NET SDK version
id: resolve-sdk
shell: bash
run: |
set -euo pipefail

pinned=""
if [ -f global.json ]; then
pinned="$(jq -r '.sdk.version // empty' global.json)"
fi

if [ -n "$pinned" ]; then
echo "global.json pins $pinned; installing that."
echo "dotnet-version=$pinned" >> "$GITHUB_OUTPUT"
else
echo "global.json pins no sdk.version; using the default (10.0.x)."
echo "dotnet-version=10.0.x" >> "$GITHUB_OUTPUT"
fi

- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6
with:
dotnet-version: "10.0.x"
dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }}

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This file is the primary instruction set for human and AI agents working in this
- `docs/wiki/Release-Flow.md` — versioning, branch models, and release strategy.
- `docs/wiki/Release-Models.md` — the three release trigger models, as raw Mermaid source.
- `docs/wiki/Local-Development.md` — `just` recipes, `release-explain`, simulation, and the local rehearsal.
- `docs/wiki/Net-11.md` — supported runtimes (and the .NET 8/9 removal) and the .NET 11 feature evaluation.
- `docs/wiki/Migration-*.md` — per-consumer migration guides.
- `README.md` — user-facing overview and minimal consumer setup.
- `.config/purview-build.json` — this repository's own pipeline configuration.
Expand All @@ -53,7 +54,7 @@ CI builds with `--warnaserror`.
## Code conventions

- C# is formatted with **CSharpier** (pinned in `.config/dotnet-tools.json`); `just lint-check` must pass. `.pre-commit` (Lefthook, `.config/lefthook.yml`) runs it.
- Target `net10.0`; the codebase uses C# modern idioms (primary constructors, collection expressions, file-scoped namespaces).
- Target `net10.0` and `net11.0` (the `PurviewTargetFrameworkSet` the SDK defaults to); the codebase uses C# modern idioms (primary constructors, collection expressions, file-scoped namespaces). Shared code must compile against the **oldest** target's API surface — see `docs/wiki/Net-11.md` for why a .NET 11-only API needs a `#if NET11_0_OR_GREATER` guard.
- Commits must follow [Conventional Commits](https://www.conventionalcommits.org) (enforced by commitlint via Lefthook). Examples: `fix:`, `feat:`, `build:`, `docs:`, `chore:`.
- Do not add code comments unless they explain non-obvious rationale (see existing comments in `Settings/` and `Modules/` for the style).
- Do not commit secrets or modify secret-handling settings without review.
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,52 @@ that version to nuget.org.
This file was reconstructed from the conventional-commit history at 0.6.1, so entries before that
release summarise what the commits recorded rather than what was written at the time.

## [Unreleased]

### Changed

- **.NET 8 and .NET 9 are no longer supported.** Both reached end of support on 2026-11-10, so
neither is a defensible baseline. Nothing in the repository targets, documents or exemplifies
`net8.0`/`net9.0` any more; the `$(TFM)` examples and the pack-validation tests now use `net11.0`.
The tool itself still targets `net10.0;net11.0`, because a single-target `net11.0` dotnet tool
would be uninstallable on every consumer running the LTS.
- **The `net11.0` leg is held to the .NET 11 analyzer set.** CA1307 now requires
`string.StartsWith(char, StringComparison)` and friends, which do not exist on `net10.0`, while
CA1865 rejects the portable single-character-string workaround. The suppression is scoped to the
newest target in `Build.csproj`, so the rule stays live on the older one.
- **`ci.yml` and `release.yml` resolve the SDK from `global.json`** instead of hardcoding a band, the
way the shared workflows already did. A pinned `global.json` and a hardcoded `dotnet-version` fail
the job at the first `dotnet` command as soon as one of them moves.
- **`PublishLocalNuGetModule` uses the asynchronous DataAnnotations validation** on the `net11.0`
target, honouring the module's cancellation token, with the synchronous call kept on `net10.0`.
- **The tool survives a NativeAOT publish.** `release-explain --format=json` threw under AOT
(`Reflection-based serialization has been disabled`) while the text rendering beside it worked,
because AOT disables reflection-based `System.Text.Json` instead of degrading. `ReleaseExplanation`
and `string[]` now serialise through the source-generated `BuildJsonContext`; the JSON output is
byte-identical and the golden file is unchanged.
- **`LangVersion` is `preview` on the `net11.0` target.** System.Text.Json's source generator emits
`safe extern` accessors, which is C# 15 syntax the compiler only accepts at `preview`. The SDK's
tier-2 memory-safety switch enables the rules but leaves the language version at 15.0, so a project
with a `JsonSerializerContext` failed with CS8652.

### Fixed

- **`dotnet restore` failed with NU1010.** `System.Security.Cryptography.Pkcs` kept its
`PackageReference` but lost its `PackageVersion`. Restoring it also stops the vulnerable 8.0.1
package that `NuGet.Packaging` pulls transitively from being resolved.
- **`just build-local-tool`, and therefore every `pipeline-*` recipe, failed with NETSDK1129.**
`dotnet publish` refuses a multi-targeted project without a single framework. The recipe now pins
`PurviewTargetFrameworkSet=Latest`; the packaged tool still ships both targets.

### Added

- `docs/wiki/Net-11.md` — the supported-runtime table, what the SDK already enables, and the .NET 11
features evaluated per area with the decision and its reason.
- `just publish-aot` — publishes the tool as a NativeAOT executable for the current platform and
smoke-tests `release-explain --format=json`, the path where reflection-based serialisation fails
silently at runtime rather than at build time. CI runs the same check on Windows, Linux and macOS as
a `fail-fast: false` matrix.

## [0.7.0] - 2026-10-21

### Changed
Expand Down
8 changes: 3 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.78.0</TUnitVersion>
<DotNetRuntimeVersion>10.0.14</DotNetRuntimeVersion>
<MSExtensionVersion>10.10.0</MSExtensionVersion>
<TUnitVersion>1.83.0</TUnitVersion>
<DotNetRuntimeVersion>11.0.0</DotNetRuntimeVersion>
<ModularPipelinesVersion>3.2.8</ModularPipelinesVersion>
<NuGetPackagingVersion>7.9.0</NuGetPackagingVersion>
</PropertyGroup>
Expand All @@ -18,7 +16,7 @@
<PackageVersion Include="TUnit.Core" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Aspire" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Mocks" Version="$(TUnitVersion)" />
<PackageVersion Include="Purview.Skills.Testing" Version="1.2.0" />
<PackageVersion Include="Purview.Skills.Testing" Version="1.3.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(RoslynVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="$(RoslynVersion)" />
Expand Down
19 changes: 18 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pipeline_tool := local_tool
golden_test_project := tests_root / "Build.UnitTests" / "Build.UnitTests.csproj"
dogfood_artifacts := "./artifacts/dogfood"
dogfood_tool_path := "./.tools/dogfood"
aot_dir := "./.tools/aot"

current_version := `bun -p "require('./package.json').version"`

Expand All @@ -42,6 +43,7 @@ ensure-pipeline-tool:
build-local-tool:
echo "Building {{ BLUE }}{{ tool_project }}{{ NORMAL }} to {{ GREEN }}{{ local_tool_dir }}{{ NORMAL }} (Release)"
dotnet publish {{ tool_project }} -c Release -o "{{ local_tool_dir }}" \
-p:PurviewTargetFrameworkSet=Latest \
-p:Version={{ current_version }} -p:PackageVersion={{ current_version }}

# Run the PR pipeline (restore, build, lint, tests)
Expand Down Expand Up @@ -105,6 +107,21 @@ pipeline-dogfood *args:
echo "Running the freshly packed tool against this repository..."
"{{ dogfood_tool_path }}/purview-build" {{ args }}

# Publish the tool as a native executable (NativeAOT) for the current platform and smoke-test it
# A published dotnet tool ships IL and picks a runtime at install time; this recipe is the separate
# question of whether the same source survives AOT, where reflection-based serialisation throws
# instead of degrading. It is the gate for the JSON path, which the smoke test exercises.
[group('Build and Test')]
publish-aot:
echo "Publishing {{ BLUE }}{{ tool_project }}{{ NORMAL }} as NativeAOT to {{ GREEN }}{{ aot_dir }}{{ NORMAL }}"
rm -rf "{{ aot_dir }}"
dotnet publish {{ tool_project }} -c Release -o "{{ aot_dir }}" \
-p:PurviewTargetFrameworkSet=Latest \
-p:PublishAot=true --use-current-runtime \
-p:Version={{ current_version }} -p:PackageVersion={{ current_version }}
"{{ aot_dir }}/Purview.Build" --version
"{{ aot_dir }}/Purview.Build" release-explain --format=json | jq -e '.verdict'

# Explain the release decision for the working tree, without running any module or mutating anything
[group('Release')]
release-explain *args:
Expand Down Expand Up @@ -234,7 +251,7 @@ lint-fix:
[group('Utilities')]
scrub:
find . -type d \( -name bin -o -name obj \) -exec rm -rf {} +
rm -rf "{{ local_tool_dir }}" "{{ dogfood_tool_path }}" "{{ dogfood_artifacts }}"
rm -rf "{{ local_tool_dir }}" "{{ dogfood_tool_path }}" "{{ dogfood_artifacts }}" "{{ aot_dir }}"
just clean
just restore --force-evaluate
dotnet build-server shutdown
1 change: 1 addition & 0 deletions docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The same implementation is available three ways:
- [Local Development](Local-Development.md)
- [Secrets and Environment Variables](Secrets-and-Environment-Variables.md)
- [Repository CI/CD](Repository-CI-CD.md)
- [.NET 11 support and feature evaluation](Net-11.md)
- [Migration: aspire-resourcekit](Migration-aspire-resourcekit.md)

## Pipeline
Expand Down
14 changes: 14 additions & 0 deletions docs/wiki/Local-Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ The output goes to `.tools/local/` (gitignored) rather than `bin/Release/`, beca
rebuilds the solution underneath itself and would otherwise overwrite the binary it is executing.
`.config/dotnet-tools.json` pins only CSharpier and `dotnet-inspect`.

`build-local-tool` publishes with `-p:PurviewTargetFrameworkSet=Latest`. `dotnet publish` refuses a
multi-targeted project without a single framework (`NETSDK1129`), and the local tool only ever runs on
this machine, so pinning to the newest target is the honest choice; the *packaged* tool keeps both
targets so consumers on the LTS can still install it.

`just publish-aot` is the separate question of whether the source survives NativeAOT, where
reflection-based serialisation throws instead of degrading. It publishes to `.tools/aot` for the
current platform and then runs `release-explain --format=json` through `jq`, which is the path that
exercises the source-generated serializer. The same check runs in CI on Windows, Linux and macOS
(`fail-fast: false`, so one platform's toolchain problem does not mask the other two). A published
dotnet tool ships IL and cannot itself be AOT, so this is a portability gate, not a second
distribution channel. See [.NET 11](Net-11.md) for the third-party trim warnings that remain.

`just pipeline-dogfood` is the separate, slower check that the *packaged* tool is installable: it
packs from source into `artifacts/dogfood`, installs from that folder alone (`--source`, not
`--add-source`), and runs the result. CI and the release workflow do the same.
Expand All @@ -30,6 +43,7 @@ packs from source into `artifacts/dogfood`, installs from that folder alone (`--
| --- | --- |
| `just build` | Build `src/Build.slnx` (Debug) |
| `just build-local-tool` | Publish the tool from source (Release) to `.tools/local`, which every `pipeline-*` recipe then runs |
| `just publish-aot` | Publish the tool as a NativeAOT executable to `.tools/aot` for the current platform and smoke-test it |
| `just test` / `just test-unit` | `just test` runs every suite, the same set CI runs; `just test-unit` narrows to `/*/*/*/*[Category=Unit]` |
| `just lint-check` / `just lint-fix` | CSharpier check / format |
| `just pack` | `dotnet pack` with the current `package.json` version |
Expand Down
Loading
Loading