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
25 changes: 23 additions & 2 deletions .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ on:

env:
CONFIGURATION: ${{ github.event.inputs.configuration || 'Release' }}
NUGET_TOKEN: ${{ secrets.NUGET_KEY }}

jobs:
validate-version:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
63 changes: 49 additions & 14 deletions docs/DEPLOYMENT_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
72 changes: 72 additions & 0 deletions tests/Morphir.Build.Tests/PublishingWorkflowTests.cs
Original file line number Diff line number Diff line change
@@ -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<YamlMappingNode>().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;
}
}
Loading