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
56 changes: 7 additions & 49 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,11 @@ 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: ${{ steps.resolve-sdk.outputs.dotnet-version }}
# The SDK comes from `actions/dotnet-sdk`, dogfooded from the working tree: global.json pins it
# and every dotnet command resolves against it, so installing a different band fails the first
# command with "A compatible .NET SDK was not found". The fallback only applies if global.json
# ever stops pinning one.
- uses: ./actions/dotnet-sdk

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

Expand Down Expand Up @@ -119,29 +98,8 @@ jobs:
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 }}
# See the build job: global.json wins, and the fallback only applies if it pins nothing.
- uses: ./actions/dotnet-sdk

# 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.
Expand Down
39 changes: 5 additions & 34 deletions .github/workflows/purview-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,41 +93,12 @@ jobs:
fetch-depth: 0
fetch-tags: true

# global.json is the single source of truth for the SDK whenever it pins one. Every dotnet
# command in the job resolves against it, so installing anything else fails the whole job at
# the first one - "A compatible .NET SDK was not found" - no matter what dotnet-version says.
# Deriving it here means a consuming repository states its SDK once, instead of repeating it
# in global.json, pr.yml and release.yml and keeping three copies in step by hand.
#
# When global.json pins nothing, the dotnet-version input applies, which is the behaviour
# every repository had before.
- name: Resolve the .NET SDK version
id: resolve-sdk
shell: bash
# Caller input reaches the script through env, never by interpolating an expression into the script: the
# value is substituted into the script text before bash sees it, so a crafted input would
# run as shell.
env:
INPUT_DOTNET_VERSION: ${{ inputs.dotnet-version }}
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 dotnet-version input ($INPUT_DOTNET_VERSION)."
echo "dotnet-version=$INPUT_DOTNET_VERSION" >> "$GITHUB_OUTPUT"
fi

- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6
# The SDK comes from `actions/dotnet-sdk`, the one implementation of "install what global.json
# pins". It is shared with this repository's own ci.yml/release.yml and with any consuming job
# that is not a call to a shared workflow, so the resolution cannot drift between them.
- uses: purview-dev/build/actions/dotnet-sdk@main
with:
dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }}
dotnet-version: ${{ inputs.dotnet-version }}

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

Expand Down
27 changes: 3 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,9 @@ 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: ${{ steps.resolve-sdk.outputs.dotnet-version }}
# See ci.yml: `actions/dotnet-sdk` installs what global.json pins, and a run that installs a
# different band fails before the pipeline starts.
- uses: ./actions/dotnet-sdk

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

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This file is the primary instruction set for human and AI agents working in this
(NuGet/NuGetGallery#11000). `purview-release.yml` forwards to it and offers no Trusted Publishing
inputs for exactly this reason.
- `actions/nuget-sources/` and `actions/prune-github-packages/` — the one implementation of the GitHub Packages source registration (used by all shared entry points) and the mirror-retention step. They live outside `.github/` because a composite action referenced from a reusable workflow cannot be resolved from there; `actions/purview-release/` follows the same rule.
- `actions/dotnet-sdk/` — the one implementation of "install the SDK `global.json` pins", used by the reusable build workflow and by consuming jobs that run `dotnet` on their own.
- `.github/workflows/ci.yml` and `release.yml` — this repository's own CI/CD, which dogfoods the tool.
- `docs/wiki/` — authoritative documentation:
- `docs/wiki/Architecture.md` — architecture and design decisions.
Expand Down Expand Up @@ -67,6 +68,7 @@ CI builds with `--warnaserror`.
- **This repository builds itself and never references its own released package.** The `Justfile` runs `.tools/local/Purview.Build`, published from the working tree; `ci.yml` and `release.yml` pack from source and install with `--source artifacts` (not `--add-source`, which leaves nuget.org able to satisfy an already-shipped version); `Purview.Build` is absent from `.config/dotnet-tools.json`. `SelfHostingTests` enforces all three. The reusable workflows and the composite action are the opposite case — they install the published tool for consuming repositories and must keep doing so.
- **GitHub Packages is additive and opt-in.** Consuming from `https://nuget.pkg.github.com/<owner>/index.json` is automatic for every repository using the shared action/workflows; publishing to it is controlled only by the caller repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable, which the release entry points map onto `NuGet:GitHubPackages:Enabled`. Never make publication automatic, never make it a replacement for NuGet.org, and never put a PAT in the shared path for publishing: publication uses `GITHUB_TOKEN` with `packages: write`. Consumption falls back to `GITHUB_TOKEN` and may use the optional, read-only organisation secret `PURVIEW_PACKAGES_READ_TOKEN` (`read:packages` only) for repositories that should read every package without per-package Actions-access grants. NuGet.org publication and `Release:Mode` semantics must not change.
- **One source-setup implementation.** `actions/nuget-sources/` is the only place that registers the organisation feed. `purview-build.yml`, `purview-release.yml`, and both composite actions call it; `ci.yml` and `release.yml` call it from `./` to dogfood it. It writes the runner's user-level NuGet configuration (never a committed `nuget.config`) and stores only a macro — `%GITHUB_TOKEN%`, or `%PURVIEW_NUGET_TOKEN%` when the optional organisation read token is used.
- **One SDK-setup implementation.** `actions/dotnet-sdk/` is the only place that turns `global.json` into an installed SDK. `purview-build.yml` calls it, and `ci.yml`/`release.yml` call it from `./` to dogfood it. Never install an SDK with `actions/setup-dotnet` directly, and never use its `global-json-file` input: when it cannot install the pinned version it falls back to the LTS channel, reports success, and leaves the job to fail at its first `dotnet` command with *"A compatible .NET SDK was not found"*. A consuming repository with a job of its own calls `purview-dev/build/actions/dotnet-sdk@main` for the same reason.
- **Trusted Publishing only from a composite action.** `NuGet/login` must stay in `actions/purview-release` (and in this repository's own `release.yml`). nuget.org validates the OIDC `job_workflow_ref` claim against the policy, and inside a reusable workflow that claim names the called repository, never the caller, so the exchange fails with *"No matching trust policy"* (NuGet/NuGetGallery#11000). `purview-release.yml` therefore declares no `trusted-publishing` or `nuget-user` input: offering one would only invite a migration that cannot work. `WorkflowParityTests` enforces the placement.
- **Do not add a `concurrency` block to `purview-release.yml`**: a shared group between caller and reusable workflow makes GitHub cancel the run as a deadlock. Callers own release serialization.
- **Do not remove the `release-branch` input** from `purview-release.yml`: every consuming repository still passes it.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ release summarise what the commits recorded rather than what was written at the

### Added

- **`actions/dotnet-sdk`** — the one implementation of "install the SDK `global.json` pins", for any job
that runs `dotnet` and is not a call to a shared workflow. It reads `.sdk.version` from the caller's
`global.json`, falls back to its `dotnet-version` input (default `10.0.x`) only when nothing is pinned,
and exposes the resolved version as an output. `purview-build.yml`, this repository's `ci.yml` and
`release.yml` all call it, so the resolution cannot drift between them, and a consuming repository's
own jobs can call it instead of repeating the script. `actions/setup-dotnet`'s `global-json-file`
input is not a substitute: when it cannot install the pinned version it falls back to the LTS
channel, reports success, and leaves the job to fail at its first `dotnet` command.
- `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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ The same implementation is available three ways:
`.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. The
release workflow forwards to the action above and cannot use Trusted Publishing.

Supporting composite actions are available to any job that needs the same behaviour outside a shared
entry point: `actions/dotnet-sdk` (installs the SDK `global.json` pins, or its `dotnet-version` input
when nothing is pinned), `actions/nuget-sources` (registers the organisation feed) and
`actions/prune-github-packages` (mirror retention). Reference them as
`purview-dev/build/actions/<name>@main`.

## Minimal repository setup (reusable workflow)

The examples below reference the workflows at `@main`, so they always run the latest version of the workflow/action code. The `@ref` suffix is required for cross-repository references and resolves the workflow/action to a specific commit; there is no `@latest`. Pin a release tag (e.g. `@v0.2.0`) instead if you want reproducible workflow code.
Expand Down
60 changes: 60 additions & 0 deletions actions/dotnet-sdk/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Purview .NET SDK

description: >-
Installs the .NET SDK the repository's global.json pins, or the dotnet-version input when global.json
pins no sdk.version. Use it in any job that runs dotnet and is not a call to a shared workflow: every
dotnet command resolves against global.json, so installing anything else fails the job at its first
dotnet command with "A compatible .NET SDK was not found", whatever dotnet-version was asked for.

outputs:
dotnet-version:
description: The SDK version that was installed - the global.json pin, or the input fallback.
value: ${{ steps.resolve-sdk.outputs.dotnet-version }}

inputs:
dotnet-version:
description: >-
.NET SDK to install when global.json pins no sdk.version. Ignored when it pins one, because
global.json is the single source of truth whenever it states a version.
required: false
default: 10.0.x

runs:
using: composite
steps:
# global.json is the single source of truth for the SDK whenever it pins one. Deriving it here means
# a repository states its SDK once, instead of repeating it in global.json, pr.yml and release.yml
# and keeping three copies in step by hand.
#
# `actions/setup-dotnet`'s own `global-json-file` input is not a substitute: it resolves the version
# itself and, when it cannot install it, falls back to the LTS channel, reports success, and leaves
# the job to fail at its first dotnet command. Resolving here and installing an explicit version is
# what makes the mismatch visible at the install instead.
#
# Caller input reaches the script through env, never by interpolating an expression into the script:
# the value is substituted into the script text before bash sees it, so a crafted input would run as
# shell.
- name: Resolve the .NET SDK version
id: resolve-sdk
shell: bash
env:
INPUT_DOTNET_VERSION: ${{ inputs.dotnet-version }}
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 dotnet-version input ($INPUT_DOTNET_VERSION)."
echo "dotnet-version=$INPUT_DOTNET_VERSION" >> "$GITHUB_OUTPUT"
fi

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