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
1 change: 1 addition & 0 deletions .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ 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:
dotnet-version: 10.0
- uses: actions/checkout@v5
- name: Publish
run: ./build.cmd test publish
env:
NuGetApiKey: ${{ secrets.NUGET_API_KEY }}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
81 changes: 67 additions & 14 deletions build-support/build/Build.Publish.cs
Original file line number Diff line number Diff line change
@@ -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<DotNetNuGetPushSettings> PushSettingsBase => _ => _
.SetSource(NuGetSource)
.SetApiKey(NuGetApiKey)
.SetSkipDuplicate(true);

Configure<DotNetNuGetPushSettings> PushSettings => _ => _;
Configure<DotNetNuGetPushSettings> PackagePushSettings => _ => _;

IEnumerable<AbsolutePath> PushPackageFiles => ArtifactsDirectory.GlobFiles("*.nupkg");

bool PushCompleteOnFailure => true;
int PushDegreeOfParallelism => 5;

/// <summary>
/// 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 <c>NuGet/login@v1</c> 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.
/// </summary>
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<string>();

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<string>();
GitHubActions?.WriteCommand("add-mask", apiKey);
Log.Information("Obtained short-lived nuget.org API key, expires {Expires}", body["expires"]?.GetValue<string>());
return apiKey;
}
}