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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Build and test

on:
push:
branches:
- "**"
pull_request:

permissions:
Expand Down
204 changes: 204 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
package_version:
description: "SemVer package version to validate; blank uses VersionPrefix"
required: false
type: string

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
package:
name: Validate and package
runs-on: ubuntu-latest
outputs:
package_version: ${{ steps.version.outputs.package_version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Check out release source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json

- name: Validate release version and source
id: version
shell: pwsh
env:
DISPATCH_VERSION: ${{ inputs.package_version }}
run: >-
./scripts/Resolve-ReleaseVersion.ps1
-EventName $env:GITHUB_EVENT_NAME
-GitRef $env:GITHUB_REF
-GitRefName $env:GITHUB_REF_NAME
-DispatchVersion $env:DISPATCH_VERSION
-GitHubOutputPath $env:GITHUB_OUTPUT

- name: Verify solution structure
shell: pwsh
run: ./scripts/Verify-SolutionStructure.ps1

- name: Restore
run: dotnet restore ChessRealms.Engine.slnx --locked-mode

- name: Release build
run: dotnet build ChessRealms.Engine.slnx --configuration Release --no-restore -p:ContinuousIntegrationBuild=true

- name: Fast tests
run: dotnet test ChessRealms.Engine.slnx --configuration Release --no-build --filter "TestCategory!=Deep" -p:ContinuousIntegrationBuild=true

- name: Pack once
run: >-
dotnet pack src/ChessRealms.Engine/ChessRealms.Engine.csproj
--configuration Release
--no-build
--output artifacts/packages
-p:PackageVersion=${{ steps.version.outputs.package_version }}
-p:ContinuousIntegrationBuild=true

- name: Verify package contents and metadata
shell: pwsh
run: >-
./scripts/Verify-Package.ps1
-PackagePath "artifacts/packages/ChessRealms.Engine.${{ steps.version.outputs.package_version }}.nupkg"
-ExpectedPackageId ChessRealms.Engine
-ExpectedPackageVersion "${{ steps.version.outputs.package_version }}"

- name: Smoke-test the packed package as a consumer
shell: pwsh
run: >-
./scripts/Test-PackageConsumer.ps1
-PackagePath "artifacts/packages/ChessRealms.Engine.${{ steps.version.outputs.package_version }}.nupkg"
-ExpectedPackageId ChessRealms.Engine
-ExpectedPackageVersion "${{ steps.version.outputs.package_version }}"

- name: Generate SHA-256 checksums
shell: pwsh
run: |
Get-ChildItem artifacts/packages/ChessRealms.Engine.*.*nupkg |
Sort-Object Name |
ForEach-Object {
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant()
"$hash $($_.Name)"
} | Set-Content artifacts/packages/SHA256SUMS.txt

- name: Upload validated release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nuget-release-${{ steps.version.outputs.package_version }}
path: |
artifacts/packages/ChessRealms.Engine.${{ steps.version.outputs.package_version }}.nupkg
artifacts/packages/ChessRealms.Engine.${{ steps.version.outputs.package_version }}.snupkg
artifacts/packages/SHA256SUMS.txt
if-no-files-found: error
retention-days: 30

publish:
name: Publish to NuGet
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: package
runs-on: ubuntu-latest
environment: nuget-production
permissions:
contents: read
id-token: write
steps:
- name: Check out release metadata
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json

- name: Download validated release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: nuget-release-${{ needs.package.outputs.package_version }}
path: artifacts/packages

- name: Verify artifact checksums
working-directory: artifacts/packages
run: sha256sum --check SHA256SUMS.txt

- name: Refuse a conflicting GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "A GitHub Release already exists for ${{ github.ref_name }}; refusing to publish a conflicting release." >&2
exit 1
fi

- name: Obtain temporary NuGet credential
id: nuget-login
uses: NuGet/login@ebc737b6fc418a6ca0073cf116ec8dc156d8b81e # v1
with:
user: ${{ vars.NUGET_USER }}

- name: Publish package and symbols
run: >-
dotnet nuget push
"artifacts/packages/ChessRealms.Engine.${{ needs.package.outputs.package_version }}.nupkg"
--api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}"
--source https://api.nuget.org/v3/index.json

github-release:
name: Create GitHub Release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [package, publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download validated release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: nuget-release-${{ needs.package.outputs.package_version }}
path: artifacts/packages

- name: Refuse to overwrite an existing release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "A GitHub Release already exists for ${{ github.ref_name }}; refusing to overwrite it." >&2
exit 1
fi

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
prerelease=()
if [[ "${{ needs.package.outputs.is_prerelease }}" == "true" ]]; then
prerelease+=(--prerelease)
fi

gh release create "${{ github.ref_name }}" \
artifacts/packages/ChessRealms.Engine.${{ needs.package.outputs.package_version }}.nupkg \
artifacts/packages/ChessRealms.Engine.${{ needs.package.outputs.package_version }}.snupkg \
artifacts/packages/SHA256SUMS.txt \
--repo "${{ github.repository }}" \
--verify-tag \
--title "ChessRealms.Engine ${{ needs.package.outputs.package_version }}" \
--generate-notes \
"${prerelease[@]}"
5 changes: 5 additions & 0 deletions ChessRealms.Engine.slnx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<Solution>
<Folder Name="/docs/" Id="a576d511-a7dd-48da-89d2-a534742656e6">
<File Path="docs/game-rules-api.md" />
<File Path="docs/releasing.md" />
</Folder>
<Folder Name="/scripts/" Id="9f3eeaa3-c0d2-4d93-af81-f93234779361">
<File Path="scripts/Resolve-ReleaseVersion.ps1" />
<File Path="scripts/Test-PackageConsumer.ps1" />
<File Path="scripts/Verify-Package.ps1" />
<File Path="scripts/Verify-SolutionStructure.ps1" />
</Folder>
<Folder Name="/Solution Items/">
Expand All @@ -19,6 +23,7 @@
<Folder Name="/Solution Items/.github/" />
<Folder Name="/Solution Items/.github/workflows/">
<File Path=".github/workflows/ci.yml" />
<File Path=".github/workflows/release.yml" />
</Folder>
<Folder Name="/src/" Id="7cc56138-8080-42ef-beb6-c075dac09fd8">
<Project Path="src/ChessRealms.Engine.Benchmark/ChessRealms.Engine.Benchmark.csproj" />
Expand Down
90 changes: 90 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Releasing ChessRealms.Engine

Releases are explicit and tag-driven. Pull requests and pushes to `main` run
ordinary CI, but only a validated `v*` tag can reach the protected NuGet
publication job. A manual run of the release workflow is always a non-publishing
dry run.

## One-time external setup

Create the GitHub Environment `nuget-production`. Require a reviewer for
production publication, restrict deployments to tags matching the release
policy, and prevent administrator bypass where the repository plan supports
those controls. Define the GitHub Actions environment variable `NUGET_USER` as
the nuget.org username/profile that owns or can publish `ChessRealms.Engine`.
It is a profile name, not an email address or API key; do not store NuGet
credentials in the repository.

On nuget.org, create a Trusted Publishing policy with:

- GitHub owner: `ChessRealms`
- Repository: `Engine`
- Workflow filename: `release.yml`
- Environment: `nuget-production`
- Package scope: `ChessRealms.Engine`

The workflow requests an OIDC token only in the protected publish job and
exchanges it for a short-lived NuGet credential immediately before publication.

## Release procedure

1. Decide the SemVer increment.
2. Open a release PR that updates `VersionPrefix` in
`src/ChessRealms.Engine/ChessRealms.Engine.csproj`.
3. Merge the release PR after CI succeeds.
4. Create the matching tag on that merged `main` commit, for example `v1.0.0`.
5. Push the tag.
6. Review the completed package validation and approve the
`nuget-production` environment deployment.
7. Verify NuGet indexing and the generated GitHub Release and its attachments.

The workflow requires the tag commit to be reachable from `main`, requires the
tag's stable version core to equal `VersionPrefix`, and packages with the exact
validated tag version. It publishes the already validated artifact rather than
building again. The GitHub Release is created only after NuGet accepts the
package.

For a safe rehearsal, dispatch the `Release` workflow manually. Leave the
version blank to use `VersionPrefix`, or enter an allowed package version without
the leading `v`; its stable core must still match `VersionPrefix`. Manual runs
validate, restore, build, test, pack, inspect, and consume the package, but cannot
publish or create a GitHub Release.

## Versioning policy

- PATCH releases contain backward-compatible fixes and internal improvements.
- MINOR releases add backward-compatible public features.
- MAJOR releases contain breaking public API or behavioral contract changes.
- Prereleases use `alpha.N`, `beta.N`, or `rc.N`, such as `v1.1.0-alpha.1`,
`v1.1.0-beta.1`, or `v1.1.0-rc.1`.
- The Git tag includes `v`; the NuGet version excludes it.

Versions are chosen deliberately in the release PR. Commit messages do not
increment versions automatically.

A prerelease uses the future stable version in `VersionPrefix`: for example,
`VersionPrefix` `1.1.0` pairs with tag `v1.1.0-rc.1`. A hotfix branches from the
appropriate supported state, applies the fix, and merges the release change to
`main`; then tag the merged `main` commit with the incremented PATCH version.
Never move or reuse a published version tag.

## Failures and reruns

Package version collisions fail publication; the workflow deliberately does not
use `--skip-duplicate`. It also refuses to overwrite an existing GitHub Release.
If publication fails, diagnose the cause and rerun only after confirming that
the package version was not accepted by NuGet. If NuGet publication succeeds but
GitHub Release creation fails before a release is created, use GitHub's
**Re-run failed jobs** action so the successful publish job is not repeated. The
release job will reuse the retained workflow artifact. Do not rerun all jobs
after a successful NuGet publication. If a failed operation nevertheless created
an incomplete GitHub Release, the workflow will refuse to overwrite it; inspect
and repair it deliberately, or remove the incomplete release before rerunning.
If NuGet accepted the package but the publish step itself was reported as failed,
do not retry publication: create the GitHub Release deliberately from the
retained, checksum-verified workflow artifacts.

After the first successful `1.0.0` publication is available from the configured
package source, add `PackageValidationBaselineVersion` `1.0.0` as an immediate
follow-up. It is intentionally not configured beforehand because restore and
package validation must not depend on an unpublished baseline.
Loading
Loading