diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e1827e..04e7206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: Build and test on: push: + branches: + - "**" pull_request: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0994de --- /dev/null +++ b/.github/workflows/release.yml @@ -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[@]}" diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx index 956cfbd..3087ea8 100644 --- a/ChessRealms.Engine.slnx +++ b/ChessRealms.Engine.slnx @@ -1,8 +1,12 @@ + + + + @@ -19,6 +23,7 @@ + diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..bc575fd --- /dev/null +++ b/docs/releasing.md @@ -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. diff --git a/scripts/Resolve-ReleaseVersion.ps1 b/scripts/Resolve-ReleaseVersion.ps1 new file mode 100644 index 0000000..268d820 --- /dev/null +++ b/scripts/Resolve-ReleaseVersion.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [string]$ProjectPath = "src/ChessRealms.Engine/ChessRealms.Engine.csproj", + + [Parameter(Mandatory)] + [ValidateSet("push", "workflow_dispatch")] + [string]$EventName, + + [string]$GitRef, + + [string]$GitRefName, + + [AllowEmptyString()] + [string]$DispatchVersion = "", + + [string]$MainBranch = "main", + + [string]$GitHubOutputPath +) + +$ErrorActionPreference = "Stop" + +$resolvedProjectPath = (Resolve-Path -LiteralPath $ProjectPath).Path +[xml]$project = Get-Content -LiteralPath $resolvedProjectPath -Raw +$versionPrefix = [string]$project.Project.PropertyGroup.VersionPrefix +$corePattern = '(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' +$packagePattern = "^(?$corePattern)(-(?