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 }}