From 918b3721858adb98712b70a5073be382d6b28aa0 Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:32:21 -0600 Subject: [PATCH] fix(release): publish NuGet packages with trusted publishing --- .github/workflows/deployment.yml | 25 ++++++- docs/DEPLOYMENT_TESTING.md | 63 ++++++++++++---- .../PublishingWorkflowTests.cs | 72 +++++++++++++++++++ 3 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 tests/Morphir.Build.Tests/PublishingWorkflowTests.cs diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 31a7e1db..e926c605 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -28,7 +28,6 @@ on: env: CONFIGURATION: ${{ github.event.inputs.configuration || 'Release' }} - NUGET_TOKEN: ${{ secrets.NUGET_KEY }} jobs: validate-version: @@ -145,7 +144,11 @@ jobs: release: runs-on: ubuntu-latest + environment: production needs: [validate-version, build-executables] + permissions: + contents: read + id-token: write # First of all the release job requires a manual trigger or tag push # Second, the release job will only run if it is provided a valid semantic version # Third, the release job will only run if the current repository is the main repository @@ -154,6 +157,15 @@ jobs: if: ${{ !failure() }} steps: + - name: Validate trusted publishing configuration + env: + NUGET_USER: ${{ vars.NUGET_USER || secrets.NUGET_USER }} + run: | + if [ -z "$NUGET_USER" ]; then + echo "::error::Set NUGET_USER to the NuGet username that created the trusted publishing policy for finos/morphir-dotnet and deployment.yml." + exit 1 + fi + - name: Fetch Source Code uses: actions/checkout@v6.0.1 with: @@ -195,8 +207,17 @@ jobs: - name: Run all tests (includes build tests) run: ./build.sh --target TestAll --configuration ${{ env.CONFIGURATION }} + # Request the short-lived credential after building and testing so it cannot expire during those steps. + - name: Authenticate to NuGet with trusted publishing + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + id: nuget-login + with: + user: ${{ vars.NUGET_USER || secrets.NUGET_USER }} + - name: Publish packages to NuGet - run: ./build.sh --target PublishAll --api-key ${{ env.NUGET_TOKEN }} --nuget-source https://api.nuget.org/v3/index.json + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: ./build.sh --target PublishAll --api-key "$NUGET_API_KEY" --nuget-source https://api.nuget.org/v3/index.json create-github-release: runs-on: ubuntu-latest diff --git a/docs/DEPLOYMENT_TESTING.md b/docs/DEPLOYMENT_TESTING.md index 1cc38697..a3b3ad1e 100644 --- a/docs/DEPLOYMENT_TESTING.md +++ b/docs/DEPLOYMENT_TESTING.md @@ -14,6 +14,36 @@ The deployment workflow (`.github/workflows/deployment.yml`) is triggered by: - [ ] All tests passing locally - [ ] Version number follows semantic versioning (e.g., `0.2.1` or `0.2.1-rc.1`) +## NuGet trusted publishing + +Deployment uses GitHub OIDC and `NuGet/login` to obtain a temporary NuGet API key +immediately before publishing. It does not use the stored `NUGET_KEY` or +`NUGET_TOKEN` secrets. Only the `release` job has `id-token: write` permission. + +Before running a release: + +1. In NuGet.org, open **Trusted Publishing** under your personal user profile. + Select the account or organization that owns the Morphir packages as the + package owner. The current policy selects `Morphir`. Confirm that the GitHub + policy trusts repository owner `finos`, repository `morphir-dotnet`, and + workflow file `deployment.yml`, without the directory path. +2. Set the policy's environment restriction to `production`, matching + `environment: production` on the workflow's `release` job. +3. Confirm the policy covers all package IDs in `LibraryPackages` in + `build/Build.cs`, plus `Morphir.Tool`. It must allow new package creation as + well as new versions for IDs that have not been published before. The package + glob `Morphir*` covers all 15 packages, including the root `Morphir` package. +4. Set the repository Actions variable `NUGET_USER` to the personal NuGet username + that creates the policy, even when an organization is selected as package + owner. Use the NuGet username, not an email address, organization name or + GitHub username. The current policy was created by `DamReev`. An existing + Actions secret named `NUGET_USER` is also accepted. No permanent API key is needed. + +The workflow checks for `NUGET_USER` before building packages. NuGet checks the +policy when `NuGet/login` exchanges the OIDC token. See the +[NuGet trusted publishing documentation](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) +for policy ownership and package scopes. + ## Testing on a Fork (Recommended) **IMPORTANT**: Always test on a fork first to avoid accidental releases to the main repository. @@ -85,7 +115,7 @@ Check that each step completes successfully: - Downloads executable artifacts - Packs NuGet packages - Runs TestAll (unit tests + build tests) - - **Note**: Will fail at NuGet publish step if you don't have NUGET_KEY secret configured (this is expected) + - **Note**: A fork without `NUGET_USER` fails the configuration check. A fork is not trusted by the main repository's NuGet policy, so OIDC login will fail even if a username is configured. - ✅ `create-github-release` job: - Downloads executables @@ -176,12 +206,20 @@ Watch the workflow execution in real-time and verify all steps complete successf ### NuGet Publish Fails -**Error**: `API_KEY is required for publishing` +Check which step failed: -**Solution**: -- On fork: This is expected unless you configure NUGET_KEY secret -- On main repo: Ensure NUGET_KEY secret is set in repository settings -- For testing: Use `skip-git-release: true` to test everything except publishing +- **Validate trusted publishing configuration**: Set the Actions variable + `NUGET_USER` to the personal NuGet username that created the trust policy. +- **Authenticate to NuGet with trusted publishing**: Check the policy's profile, + repository owner, repository, workflow filename, environment restriction, + activation status and package scopes against the configuration above. +- **Publish packages to NuGet**: Check the temporary credential's package scopes + and whether the policy allows both new packages and new versions. Do not + restore a stored API key as a workaround. + +`skip-git-release: true` only skips creation of the GitHub release. It still +publishes NuGet packages, so it is not a publishing dry run. A fork without its +own trust policy cannot complete NuGet publishing. ## Production Deployment Checklist @@ -276,15 +314,12 @@ If deployment fails: - Verify workflow file has valid YAML syntax - Check repository Actions settings (Actions must be enabled) -### Secrets Not Available +### Publishing Credentials Not Available -**Issue**: GITHUB_TOKEN or NUGET_KEY not found - -**Solutions**: -- GITHUB_TOKEN: Automatically provided by GitHub Actions -- NUGET_KEY: Must be configured in repository secrets - - Go to Settings > Secrets and variables > Actions - - Add secret named `NUGET_KEY` with your NuGet API key +GitHub provides `GITHUB_TOKEN` automatically. NuGet publishing requires the +`NUGET_USER` profile name and a matching trusted publishing policy, as described +above. `NuGet/login` supplies the temporary API key through its step output. +The old `NUGET_KEY` and `NUGET_TOKEN` secrets are no longer read by deployment. ### Permission Denied diff --git a/tests/Morphir.Build.Tests/PublishingWorkflowTests.cs b/tests/Morphir.Build.Tests/PublishingWorkflowTests.cs new file mode 100644 index 00000000..05fd2979 --- /dev/null +++ b/tests/Morphir.Build.Tests/PublishingWorkflowTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using TUnit.Core; +using YamlDotNet.RepresentationModel; + +namespace Morphir.Build.Tests; + +public class PublishingWorkflowTests +{ + [Test] + public void Release_UsesTheEnvironmentRequiredByTheNuGetPolicy() + { + var release = (YamlMappingNode)ReadWorkflow()["jobs"]["release"]; + release.Children.Should().ContainKey(new YamlScalarNode("environment")); + release["environment"].ToString().Should().Be("production"); + } + + [Test] + public void Release_PublishesWithTheTrustedPublishingCredential() + { + var workflow = ReadWorkflow(); + var release = (YamlMappingNode)workflow["jobs"]["release"]; + var steps = ((YamlSequenceNode)release["steps"]).Children.Cast().ToList(); + var publish = steps.Single(step => step.Children.TryGetValue("name", out var name) + && name.ToString() == "Publish packages to NuGet"); + var login = steps[steps.IndexOf(publish) - 1]; + + login.Children.Should().ContainKey(new YamlScalarNode("uses"), + "publishing must immediately follow a trusted publishing login"); + login["uses"].ToString().Should().StartWith("NuGet/login@"); + var loginId = login["id"].ToString(); + var environment = (YamlMappingNode)publish["env"]; + environment["NUGET_API_KEY"].ToString().Should() + .Be("${{ steps." + loginId + ".outputs.NUGET_API_KEY }}"); + publish["run"].ToString().Should().Contain("--api-key \"$NUGET_API_KEY\""); + workflow.ToString().Should().NotContain("secrets.NUGET_KEY").And.NotContain("secrets.NUGET_TOKEN"); + } + + [Test] + public void OnlyTheReleaseJob_CanRequestAnOidcToken() + { + var workflow = ReadWorkflow(); + var permissions = (YamlMappingNode)workflow["permissions"]; + permissions.Children.Should().NotContainKey(new YamlScalarNode("id-token")); + + var jobs = (YamlMappingNode)workflow["jobs"]; + foreach (var (name, node) in jobs.Children) + { + var job = (YamlMappingNode)node; + if (name.ToString() == "release") + { + job.Children.Should().ContainKey(new YamlScalarNode("permissions")); + var jobPermissions = (YamlMappingNode)job["permissions"]; + jobPermissions["id-token"].ToString().Should().Be("write"); + jobPermissions["contents"].ToString().Should().Be("read"); + } + else if (job.Children.TryGetValue("permissions", out var jobPermissions)) + { + ((YamlMappingNode)jobPermissions).Children.Should() + .NotContainKey(new YamlScalarNode("id-token")); + } + } + } + + private static YamlMappingNode ReadWorkflow() + { + using var reader = File.OpenText(Path.Combine(TestFixture.GetRepositoryRoot(), + ".github", "workflows", "deployment.yml")); + var yaml = new YamlStream(); + yaml.Load(reader); + return (YamlMappingNode)yaml.Documents[0].RootNode; + } +}