diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index c762f7ff0..f10a8744a 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -133,6 +133,7 @@ }, "NuGetApiKey": { "type": "string", + "description": "Explicit nuget.org API key - when omitted, a short-lived key is minted from GitHub OIDC", "default": "Secrets must be entered via 'fallout :secrets [profile]'" }, "NuGetSource": { diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5085eb001..89557e803 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,10 +5,17 @@ on: tags: - 'v*.*.*' +# Packages are pushed with NuGet trusted publishing: the job's OIDC token is exchanged for a +# short-lived nuget.org API key inside the build, so no long-lived key is stored anywhere. +permissions: + id-token: write + contents: read + jobs: publish: runs-on: windows-latest + environment: nuget steps: - uses: actions/setup-dotnet@v5 with: @@ -16,5 +23,3 @@ jobs: - uses: actions/checkout@v5 - name: Publish run: ./build.cmd test publish - env: - NuGetApiKey: ${{ secrets.NUGET_API_KEY }} diff --git a/AGENTS.md b/AGENTS.md index 72ceff780..b6a37752f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for AI coding agents working in this repository. Human-facing documenta ## Project overview -Spring.NET is a port and extension of the Java Spring Framework for .NET: IoC container, AOP, expression language, declarative transaction management, ADO.NET framework, ASP.NET (WebForms/MVC5/WebAPI) integration, NHibernate 5, Quartz.NET, messaging (MSMQ, NMS, TIBCO EMS), NVelocity templating, and NUnit/MSTest testing support. Apache 2.0 licensed. Packages are published to NuGet from git tags (`v*.*.*`). +Spring.NET is a port and extension of the Java Spring Framework for .NET: IoC container, AOP, expression language, declarative transaction management, ADO.NET framework, ASP.NET (WebForms/MVC5/WebAPI) integration, NHibernate 5, Quartz.NET, messaging (MSMQ, NMS, TIBCO EMS), NVelocity templating, and NUnit/MSTest testing support. Apache 2.0 licensed. Packages are published to NuGet from git tags (`v*.*.*`) via NuGet Trusted Publishing (GitHub OIDC), not a stored API key. ## Build and test commands @@ -63,6 +63,7 @@ Key mechanics (defined in `src/Directory.Build.props` and `test/Directory.Build. - **Central package management**: versions live in `Directory.Packages.props`; csproj `PackageReference`s are versionless. - **ANTLR expression parser**: the grammar is `src/Spring/Spring.Core/Expressions/Expression.g`. The generated lexer/parser under `Expressions/Parser/` and a vendored, hand-patched ANTLR 2.7.7 runtime under `Expressions/Parser/antlr/` are **committed — never hand-edit generated files**; regenerate with `./build.cmd Antlr` (see `src/Spring/Spring.Core/README_ANTLR.txt`). - **Versioning**: the git tag is the version authority (`v3.1.0` → 3.1.0); untagged builds get a `dev-`/`preview-` suffix. Publishing runs from the tag workflow only. +- **Trusted publishing**: `.github/workflows/publish.yml` is the only workflow allowed to push to nuget.org. It needs `permissions: id-token: write` and `environment: nuget`; the `Publish` target exchanges that OIDC token for a short-lived API key in `build-support/build/Build.Publish.cs`. The nuget.org policy is keyed to the **workflow filename** `publish.yml` plus the `nuget` environment, so renaming, moving or splitting that workflow breaks publishing until the policy is updated. Do not reintroduce a `NUGET_API_KEY` secret — the `NuGetApiKey` parameter stays only as a manual override. - In test projects, `ILog` is a global using alias for `Microsoft.Extensions.Logging.ILogger`. ## Code style diff --git a/build-support/build/Build.Publish.cs b/build-support/build/Build.Publish.cs index d54e8d95d..a15ce81c3 100644 --- a/build-support/build/Build.Publish.cs +++ b/build-support/build/Build.Publish.cs @@ -1,41 +1,94 @@ +using System; using System.Collections.Generic; +using System.Net.Http; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; +using Fallout.Common.Utilities.Net; +using Serilog; using static Fallout.Common.Tools.DotNet.DotNetTasks; public partial class Build { + // Trusted publishing (https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing): the + // audience and token exchange endpoint nuget.org expects, and the nuget.org profile name of the + // account that created the trusted publishing policy. + const string NuGetAudience = "https://www.nuget.org"; + const string NuGetTokenServiceUrl = "https://www.nuget.org/api/v2/token"; + const string NuGetUser = "lahma"; + [Parameter] string NuGetSource => "https://api.nuget.org/v3/index.json"; - [Parameter] [Secret] string NuGetApiKey; + + [Parameter("Explicit nuget.org API key - when omitted, a short-lived key is minted from GitHub OIDC")] + [Secret] + readonly string NuGetApiKey; Target Publish => _ => _ .OnlyWhenDynamic(() => IsRunningOnWindows && IsTaggedBuild) .DependsOn(Pack) - .Requires(() => NuGetApiKey) .Executes(() => { + // Minted once per release: nuget.org allows one key per 30 seconds per user, and a single + // OIDC token mints exactly one key. + var apiKey = !string.IsNullOrWhiteSpace(NuGetApiKey) + ? NuGetApiKey + : GetTrustedPublishingApiKey(); + DotNetNuGetPush(_ => _ - .Apply(PushSettingsBase) - .Apply(PushSettings) + .SetSource(NuGetSource) + .SetApiKey(apiKey) + .SetSkipDuplicate(true) .CombineWith(PushPackageFiles, (_, v) => _ - .SetTargetPath(v)) - .Apply(PackagePushSettings), + .SetTargetPath(v)), PushDegreeOfParallelism, PushCompleteOnFailure); }); - Configure PushSettingsBase => _ => _ - .SetSource(NuGetSource) - .SetApiKey(NuGetApiKey) - .SetSkipDuplicate(true); - - Configure PushSettings => _ => _; - Configure PackagePushSettings => _ => _; - IEnumerable PushPackageFiles => ArtifactsDirectory.GlobFiles("*.nupkg"); bool PushCompleteOnFailure => true; int PushDegreeOfParallelism => 5; + + /// + /// Exchanges the job's GitHub OIDC token for a short-lived nuget.org API key, so no long-lived + /// key has to be stored anywhere. Does what NuGet/login@v1 does, without taking a + /// dependency on the marketplace action - and late enough in the build that the key is seconds + /// old by the time the push runs. + /// + string GetTrustedPublishingApiKey() + { + const string missingOidc = "GitHub OIDC is unavailable - the job needs 'permissions: id-token: write'"; + var requestUrl = Assert.NotNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL"), missingOidc); + var requestToken = Assert.NotNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), missingOidc); + + using var client = new HttpClient(); + // nuget.org's token endpoint returns HTTP 400 for a request with no User-Agent, and HttpClient + // sends none by default. Any non-empty value satisfies it. + client.DefaultRequestHeaders.UserAgent.ParseAdd("spring-net-build/1.0"); + + var idToken = client + .CreateRequest(HttpMethod.Get, $"{requestUrl}&audience={Uri.EscapeDataString(NuGetAudience)}") + .WithBearerAuthentication(requestToken) + .GetResponse() + .AssertSuccessfulStatusCode() + .GetBodyAsJsonObject().GetAwaiter().GetResult()["value"].GetValue(); + + var body = client + .CreateRequest(HttpMethod.Post, NuGetTokenServiceUrl) + .WithBearerAuthentication(idToken) + .WithJsonContent(new { username = NuGetUser, tokenType = "ApiKey" }) + .GetResponse() + .AssertResponse(x => x.IsSuccessStatusCode + ? null + : $"nuget.org token exchange failed ({(int) x.StatusCode}): {x.Content.ReadAsStringAsync().GetAwaiter().GetResult()}. " + + $"Check that a trusted publishing policy exists for this repository and workflow file, and that '{NuGetUser}' created it.") + .GetBodyAsJsonObject().GetAwaiter().GetResult(); + + // The action reads 'apiKey', the original design document says 'api_key' - accept either. + var apiKey = (body["apiKey"] ?? body["api_key"]).GetValue(); + GitHubActions?.WriteCommand("add-mask", apiKey); + Log.Information("Obtained short-lived nuget.org API key, expires {Expires}", body["expires"]?.GetValue()); + return apiKey; + } }