diff --git a/.github/workflows/CreateRelease.yml b/.github/workflows/CreateRelease.yml
new file mode 100644
index 00000000..20a8d3f5
--- /dev/null
+++ b/.github/workflows/CreateRelease.yml
@@ -0,0 +1,56 @@
+name: CreateRelease
+
+on:
+ workflow_dispatch:
+
+permissions: { }
+
+jobs:
+
+ SetupBuildInfo:
+ runs-on: ubuntu-latest
+ outputs:
+ build-name: ${{ steps.SetupBuildInfo.outputs.build-name }}
+ build-id: ${{ steps.SetupBuildInfo.outputs.build-id }}
+ build-version: ${{ steps.SetupBuildInfo.outputs.build-version }}
+ build-timestamp: ${{ steps.SetupBuildInfo.outputs.build-timestamp }}
+ steps:
+
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET 10.0.x
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: SetupBuildInfo
+ id: SetupBuildInfo
+ run: dotnet run --project _atom/_atom.csproj -- SetupBuildInfo --skip --headless
+
+ CreateGithubRelease:
+ permissions:
+ contents: write
+ needs: [ SetupBuildInfo ]
+ runs-on: ubuntu-latest
+ steps:
+
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET 10.0.x
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: CreateGithubRelease
+ id: CreateGithubRelease
+ run: dotnet run --project _atom/_atom.csproj -- CreateGithubRelease --skip --headless
+ env:
+ build-version: ${{ needs.SetupBuildInfo.outputs.build-version }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
diff --git a/Invex.Atom.sln.DotSettings b/Invex.Atom.sln.DotSettings
index 0611a601..b3880ba2 100644
--- a/Invex.Atom.sln.DotSettings
+++ b/Invex.Atom.sln.DotSettings
@@ -1,3 +1,4 @@
True
+ True
\ No newline at end of file
diff --git a/_atom/IBuild.cs b/_atom/IBuild.cs
index 3637db94..fecb196b 100644
--- a/_atom/IBuild.cs
+++ b/_atom/IBuild.cs
@@ -213,6 +213,26 @@ internal interface IBuild : IWorkflowBuildDefinition,
],
Types = [WorkflowTypes.Github.Action],
},
+ new("CreateRelease")
+ {
+ Triggers = [WorkflowTriggers.Manual],
+ Targets =
+ [
+ new(nameof(SetupBuildInfo)),
+ new(nameof(CreateGithubRelease))
+ {
+ Options =
+ [
+ BuildOptions.Inject.Secret(nameof(GithubToken)),
+ new GithubTokenPermissionsOption(new Permissions.Exact(new()
+ {
+ Contents = PermissionsLevel.Write,
+ })),
+ ],
+ },
+ ],
+ Types = [WorkflowTypes.Github.Action],
+ },
// Test devops
new("Test_Devops_Build")
diff --git a/_atom/Targets/IDeployTargets.cs b/_atom/Targets/IDeployTargets.cs
index 6814905b..421cc62c 100644
--- a/_atom/Targets/IDeployTargets.cs
+++ b/_atom/Targets/IDeployTargets.cs
@@ -61,4 +61,14 @@ await PushPackageToNuget(
foreach (var artifact in IBuildTargets.ProjectsToPack.Concat(ITestTargets.ProjectsToTest))
await UploadArtifactToRelease(artifact, $"v{BuildVersion}");
});
+
+ Target CreateGithubRelease =>
+ d => d
+ .DescribedAs("Creates a release on GitHub.")
+ .RequiresParam(nameof(GithubToken))
+ .ConsumesVariable(nameof(SetupBuildInfo), nameof(BuildVersion))
+ .Executes(async () => await CreateRelease(
+ $"v{BuildVersion.Major}.{BuildVersion.Minor}.{BuildVersion.Patch}",
+ "main",
+ $"v{BuildVersion.Major}.{BuildVersion.Minor}.{BuildVersion.Patch}"));
}
diff --git a/docs/modules/github-workflows.md b/docs/modules/github-workflows.md
index febe54c7..7545ca26 100644
--- a/docs/modules/github-workflows.md
+++ b/docs/modules/github-workflows.md
@@ -50,11 +50,28 @@ if (Github.IsGithubActions)
}
```
+### Release Helper
+
+Implement `IGithubReleaseHelper` to create GitHub Releases and upload artifacts from your targets. `CreateRelease`
+tags a commit (or the latest commit on a branch via `targetCommitish`) and creates the release for that tag:
+
+```csharp
+// Tag the latest commit on the 'main' branch and create a release for it.
+await CreateRelease($"v{BuildVersion}", "main", name: $"v{BuildVersion}");
+
+// Tag a specific commit SHA instead.
+await CreateRelease("v1.2.3", "0a1b2c3d", body: "Release notes...", prerelease: true);
+```
+
+Use `UploadArtifactToRelease` / `UploadAssetToRelease` to attach assets to an existing release. When not running in
+GitHub Actions these operations are simulated (logged only) by default.
+
### Variable Provider
`GithubVariableProvider` writes variables to `$GITHUB_OUTPUT` and reads them from job outputs, enabling cross-job data
sharing.
+
### Report Writer
`GithubSummaryOutcomeReportWriter` writes build report data to the GitHub Actions job summary (`$GITHUB_STEP_SUMMARY`).
diff --git a/src/Invex.Atom.Module.GithubWorkflows/Helpers/IGithubReleaseHelper.cs b/src/Invex.Atom.Module.GithubWorkflows/Helpers/IGithubReleaseHelper.cs
index f99a0074..31eef4c9 100644
--- a/src/Invex.Atom.Module.GithubWorkflows/Helpers/IGithubReleaseHelper.cs
+++ b/src/Invex.Atom.Module.GithubWorkflows/Helpers/IGithubReleaseHelper.cs
@@ -10,6 +10,80 @@
[PublicAPI]
public interface IGithubReleaseHelper : IGithubHelper
{
+ ///
+ /// Creates a GitHub Release for a new tag, tagging a specific commit or the latest commit on a branch.
+ ///
+ ///
+ /// The name of the tag to create for the release (e.g. "v1.0.0"). The tag is created if it does not
+ /// already exist.
+ ///
+ ///
+ /// Specifies the commitish value that determines where the Git tag is created from. This can be a branch name
+ /// (in which case the tag is created from the latest commit on that branch) or a commit SHA (in which case the
+ /// tag is created from that specific commit). If null or empty, the repository's default branch is used.
+ ///
+ ///
+ /// The display name of the release. If null, the is used as the
+ /// release name.
+ ///
+ /// The description/body text of the release. May be null for an empty body.
+ /// If true, the release is created as an unpublished draft. Defaults to false.
+ /// If true, the release is flagged as a pre-release. Defaults to false.
+ ///
+ /// If true (default), the create operation will be simulated (logged but not executed)
+ /// when the build is not running in a GitHub Actions environment.
+ ///
+ ///
+ /// A that resolves to the created , or null when the operation
+ /// was simulated because the build is not running in GitHub Actions.
+ ///
+ /// Thrown if the GitHub repository ID cannot be parsed.
+ ///
+ /// GitHub automatically creates the Git tag from
+ /// as part of creating the release.
+ ///
+ [PublicAPI]
+ async Task CreateRelease(
+ string tagName,
+ string? targetCommitish = null,
+ string? name = null,
+ string? body = null,
+ bool draft = false,
+ bool prerelease = false,
+ bool dryRunWhenNotRunningInGithubActions = true)
+ {
+ if (!Github.IsGithubActions && dryRunWhenNotRunningInGithubActions)
+ {
+ Logger.LogWarning(
+ "Not running in GitHub Actions, simulating creation of release {TagName} targeting {TargetCommitish}.",
+ tagName,
+ string.IsNullOrEmpty(targetCommitish)
+ ? ""
+ : targetCommitish);
+
+ return null;
+ }
+
+ var client = new GitHubClient(new("Invex.Atom"), new InMemoryCredentialStore(new(GithubToken)));
+
+ var newRelease = new NewRelease(tagName)
+ {
+ Name = name ?? tagName,
+ Body = body,
+ Draft = draft,
+ Prerelease = prerelease,
+ };
+
+ if (!string.IsNullOrEmpty(targetCommitish))
+ newRelease.TargetCommitish = targetCommitish;
+
+ if (!long.TryParse(Github.Variables.RepositoryId, out var repositoryId))
+ throw new InvalidOperationException(
+ $"Unable to parse GitHub repository id from '{Github.VariableNames.RepositoryId}' (value: '{Github.Variables.RepositoryId}').");
+
+ return await client.Repository.Release.Create(repositoryId, newRelease);
+ }
+
///
/// Uploads a build artifact to a specified GitHub Release.
///