From d632bdbf7cbf059c22475e7a7a53f56e7c7f1fa9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 15:58:54 +0000 Subject: [PATCH] feat(ci): extract the global.json SDK resolution into actions/dotnet-sdk actions/setup-dotnet's global-json-file input resolves the version itself and, when it cannot install it, falls back to the LTS channel and reports success. The job then fails at its first dotnet command with "A compatible .NET SDK was not found", several steps after the message that named the version. purview-build.yml already resolved the pin from global.json and installed an explicit version, but ci.yml and release.yml duplicated that script, and a consuming repository's own jobs had no way to get it at all: telemetry-sourcegenerator's four pr.yml jobs hand-rolled setup-dotnet with global-json-file and failed for exactly that reason. This is the one implementation, following the nuget-sources precedent: the reusable workflow calls it, ci.yml and release.yml dogfood it from ./actions, and a consuming job calls purview-dev/build/actions/dotnet-sdk@main. --- .github/workflows/ci.yml | 56 ++++----------------------- .github/workflows/purview-build.yml | 39 +++---------------- .github/workflows/release.yml | 27 ++----------- AGENTS.md | 2 + CHANGELOG.md | 8 ++++ README.md | 6 +++ actions/dotnet-sdk/action.yml | 60 +++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 107 deletions(-) create mode 100644 actions/dotnet-sdk/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad827fa..0488f37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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. diff --git a/.github/workflows/purview-build.yml b/.github/workflows/purview-build.yml index 329ee34..210f37c 100644 --- a/.github/workflows/purview-build.yml +++ b/.github/workflows/purview-build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed37763..e5c4bc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7f552a9..c5f64fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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//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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 236356e..5f48314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f2d3a7c..ff2230c 100644 --- a/README.md +++ b/README.md @@ -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/@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. diff --git a/actions/dotnet-sdk/action.yml b/actions/dotnet-sdk/action.yml new file mode 100644 index 0000000..cae0ca5 --- /dev/null +++ b/actions/dotnet-sdk/action.yml @@ -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 }}