From 97676a173bd604e0eea64c62edb7d48196dbbb9e Mon Sep 17 00:00:00 2001 From: Eric Sun <141227631+EricSun0218@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:17:03 +0800 Subject: [PATCH] Automate versioned releases --- .github/workflows/release.yml | 139 +++++++++++++++++++++++++++++- README.md | 10 +++ README.zh-CN.md | 10 +++ engines/godot/build-package.ps1 | 1 + engines/godot/test-package.ps1 | 7 ++ engines/unity/build-package.ps1 | 1 + engines/unity/test-package.ps1 | 8 ++ tools/New-ReleaseBundle.ps1 | 145 ++++++++++++++++++++++++++++++++ 8 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 tools/New-ReleaseBundle.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cac8b3..a1c480d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Build release artifacts +name: Release on: workflow_dispatch: @@ -7,6 +7,11 @@ on: description: Semantic version for the artifacts required: true default: 0.3.0-alpha.1 + publish: + description: Publish NuGet packages and the GitHub pre-release + required: true + default: false + type: boolean permissions: contents: read @@ -57,8 +62,134 @@ jobs: - name: Validate public tree shell: pwsh run: ./tools/Test-PublicTree.ps1 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + - name: Build release bundle + shell: pwsh + run: ./tools/New-ReleaseBundle.ps1 -Version $env:RELEASE_VERSION + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: OpenGameAgent-${{ inputs.version }} - path: artifacts + name: OpenGameAgent-release-${{ inputs.version }} + path: artifacts/release if-no-files-found: error + + stage-github-release: + if: ${{ inputs.publish }} + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ github.sha }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: OpenGameAgent-release-${{ inputs.version }} + path: release-assets + - name: Stage immutable GitHub pre-release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="v${RELEASE_VERSION}" + + existing_release="$(gh release view "${tag}" --json isDraft --jq .isDraft 2>/dev/null || true)" + if [[ "${existing_release}" == "false" ]]; then + echo "::error::Release ${tag} is already public and cannot be replaced." + exit 1 + fi + if [[ "${existing_release}" == "true" ]]; then + gh release delete "${tag}" --yes + fi + + tag_commit="$(gh api "repos/${GH_REPO}/commits/${tag}" --jq .sha 2>/dev/null || true)" + release_target=(--target "${SOURCE_COMMIT}") + if [[ -n "${tag_commit}" ]]; then + if [[ "${tag_commit}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::Tag ${tag} points to ${tag_commit}, expected ${SOURCE_COMMIT}." + exit 1 + fi + release_target=(--verify-tag) + fi + + mapfile -t assets < <( + find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print | + sort + ) + if [[ "${#assets[@]}" -ne 10 ]]; then + echo "::error::Expected 10 downloadable assets, found ${#assets[@]}." + printf '%s\n' "${assets[@]}" + exit 1 + fi + + gh release create "${tag}" + "${release_target[@]}" + --draft + --prerelease + --title "OpenGameAgent ${tag}" + --notes-file release-assets/RELEASE_NOTES.md + "${assets[@]}" + + expected="$(printf '%s\n' "${assets[@]##*/}" | sort)" + actual="$(gh release view "${tag}" --json assets --jq '.assets[].name' | sort)" + if [[ "${actual}" != "${expected}" ]]; then + echo "::error::Draft release asset set is incomplete." + diff -u <(printf '%s\n' "${expected}") <(printf '%s\n' "${actual}") || true + exit 1 + fi + + publish-nuget: + if: ${{ inputs.publish }} + needs: stage-github-release + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + env: + RELEASE_VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + env: + DOTNET_INSTALL_DIR: ${{ runner.temp }}/.dotnet + with: + global-json-file: global.json + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: OpenGameAgent-release-${{ inputs.version }} + path: release-assets + - name: Exchange GitHub OIDC token for a temporary NuGet key + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + - name: Publish NuGet packages + shell: bash + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: | + set -euo pipefail + mapfile -t packages < <(find release-assets -maxdepth 1 -type f -name '*.nupkg' -print | sort) + if [[ "${#packages[@]}" -ne 6 ]]; then + echo "::error::Expected 6 NuGet packages, found ${#packages[@]}." + exit 1 + fi + for package in "${packages[@]}"; do + dotnet nuget push "${package}" + --api-key "${NUGET_API_KEY}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + done + + publish-github-release: + if: ${{ inputs.publish }} + needs: publish-nuget + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ inputs.version }} + steps: + - name: Publish GitHub pre-release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="v${RELEASE_VERSION}" + gh release edit "${tag}" --draft=false --prerelease + gh release view "${tag}" + --json isDraft,isPrerelease + --jq 'select(.isDraft == false and .isPrerelease == true)' >/dev/null diff --git a/README.md b/README.md index 14c4319..1d9217b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ Inputs are bounded JSON. They may represent dialogue, combat observations, simul > Current version: `0.3.0-alpha.1`. Public APIs can change before `1.0`. +## Install + +Install the complete game runtime from NuGet: + +```bash +dotnet add package OpenGameAgent --version 0.3.0-alpha.1 +``` + +The kernel, persistence, providers, and engine-compatible client are also published as separate `OpenGameAgent.*` packages. Godot, Unity, and portable server archives are available on the [Releases](https://github.com/EricSun0218/OpenGameAgent/releases) page. See [Getting started](docs/getting-started.md) and [Engine integration](docs/engine-integration.md) before connecting a game. + ## Why game-specific? General agent loops commonly assume one user, wall-clock time, and a linear conversation. A game may have multiple timelines, save forks, thousands of actors, offline time jumps, engine main-thread constraints, and actions that must never be repeated after an uncertain failure. diff --git a/README.zh-CN.md b/README.zh-CN.md index b497e63..9d7ede4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,6 +14,16 @@ OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的 > 当前版本:`0.3.0-alpha.1`。在 `1.0` 前公开 API 仍可能调整。 +## 安装 + +从 NuGet 安装完整的游戏 Runtime: + +```bash +dotnet add package OpenGameAgent --version 0.3.0-alpha.1 +``` + +内核、持久化、模型提供方和引擎兼容客户端也分别提供 `OpenGameAgent.*` 包。Godot、Unity 与可移植服务端压缩包可以从 [Releases](https://github.com/EricSun0218/OpenGameAgent/releases) 页面下载。接入游戏前请阅读[快速开始](docs/getting-started.md)和[引擎接入](docs/engine-integration.md)。 + ## 为什么要做游戏专用层 通用 Agent 往往默认单用户、现实时间和线性对话。游戏却可能拥有多条时间线、存档分支、成千上万个角色、离线时间跳跃、引擎主线程限制,以及不能在故障后盲目重试的状态变更。 diff --git a/engines/godot/build-package.ps1 b/engines/godot/build-package.ps1 index b064ce1..0169be7 100644 --- a/engines/godot/build-package.ps1 +++ b/engines/godot/build-package.ps1 @@ -42,6 +42,7 @@ if (Test-Path -LiteralPath $packageRoot) { } New-Item -ItemType Directory -Path (Join-Path $packagedAddon 'runtime'), (Join-Path $packagedAddon 'lib') -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $repositoryRoot 'LICENSE') -Destination (Join-Path $packageRoot 'LICENSE') Copy-Item -LiteralPath (Join-Path $addonRoot 'runtime\OpenGameAgentNode.cs') -Destination (Join-Path $packagedAddon 'runtime\OpenGameAgentNode.cs') Copy-Item -LiteralPath (Join-Path $addonRoot 'OpenGameAgent.Godot.props') -Destination (Join-Path $packagedAddon 'OpenGameAgent.Godot.props') Copy-Item -LiteralPath (Join-Path $addonRoot 'README.md') -Destination (Join-Path $packagedAddon 'README.md') diff --git a/engines/godot/test-package.ps1 b/engines/godot/test-package.ps1 index 1c35c5e..bc2c20e 100644 --- a/engines/godot/test-package.ps1 +++ b/engines/godot/test-package.ps1 @@ -14,6 +14,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Building the Godot package failed.' } $packageRoot = [string]$buildOutput[-1] $required = @( + 'LICENSE', 'addons\open_game_agent\plugin.cfg', 'addons\open_game_agent\OpenGameAgent.Godot.props', 'addons\open_game_agent\runtime\OpenGameAgentNode.cs', @@ -27,4 +28,10 @@ foreach ($relative in $required) { } } +$sourceLicenseHash = (Get-FileHash -LiteralPath (Join-Path $engineRoot 'LICENSE') -Algorithm SHA256).Hash +$packagedLicenseHash = (Get-FileHash -LiteralPath (Join-Path $packageRoot 'LICENSE') -Algorithm SHA256).Hash +if ($packagedLicenseHash -ne $sourceLicenseHash) { + throw 'Godot package must contain the complete repository license.' +} + $packageRoot diff --git a/engines/unity/build-package.ps1 b/engines/unity/build-package.ps1 index 6271078..48a3c48 100644 --- a/engines/unity/build-package.ps1 +++ b/engines/unity/build-package.ps1 @@ -42,6 +42,7 @@ if (Test-Path -LiteralPath $packagePath) { New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null Copy-Item -LiteralPath (Join-Path $engineRoot 'Packages\com.opengameagent.runtime') -Destination $packagePath -Recurse +Copy-Item -LiteralPath (Join-Path $repositoryRoot 'LICENSE') -Destination (Join-Path $packagePath 'LICENSE.md') -Force $manifestPath = Join-Path $packagePath 'package.json' $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json $manifest.version = $Version diff --git a/engines/unity/test-package.ps1 b/engines/unity/test-package.ps1 index a0a04ee..95f44d4 100644 --- a/engines/unity/test-package.ps1 +++ b/engines/unity/test-package.ps1 @@ -13,6 +13,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Building the Unity package failed.' } $packagePath = [string]$buildOutput[-1] $required = @( + 'LICENSE.md', 'package.json', 'package.json.meta', 'Runtime.meta', @@ -35,6 +36,13 @@ foreach ($relative in $required) { } } +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $engineRoot) +$sourceLicenseHash = (Get-FileHash -LiteralPath (Join-Path $repositoryRoot 'LICENSE') -Algorithm SHA256).Hash +$packagedLicenseHash = (Get-FileHash -LiteralPath (Join-Path $packagePath 'LICENSE.md') -Algorithm SHA256).Hash +if ($packagedLicenseHash -ne $sourceLicenseHash) { + throw 'Unity package must contain the complete repository license.' +} + $unexpected = Get-ChildItem -LiteralPath $packagePath -Recurse -File | Where-Object { $_.Name -match '^(GameAgent\.|OpenGameAgent\.Unity\.dll)' } if ($unexpected) { diff --git a/tools/New-ReleaseBundle.ps1 b/tools/New-ReleaseBundle.ps1 new file mode 100644 index 0000000..cf6df7a --- /dev/null +++ b/tools/New-ReleaseBundle.ps1 @@ -0,0 +1,145 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Version, + [string] $ArtifactsDirectory, + [string] $OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') { + throw 'Version must be a semantic version.' +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { + $ArtifactsDirectory = Join-Path $repositoryRoot 'artifacts' +} +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $ArtifactsDirectory 'release' +} + +$artifactsRoot = [IO.Path]::GetFullPath($ArtifactsDirectory) +$outputRoot = [IO.Path]::GetFullPath($OutputDirectory) +if (-not (Test-Path -LiteralPath $artifactsRoot -PathType Container)) { + throw "Artifacts directory '$artifactsRoot' does not exist." +} +if (-not $outputRoot.StartsWith($artifactsRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Release output must be a child of the artifacts directory.' +} +if (Test-Path -LiteralPath $outputRoot) { + throw "Release output '$outputRoot' already exists." +} + +New-Item -ItemType Directory -Path $outputRoot | Out-Null +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function New-DirectoryArchive { + param( + [Parameter(Mandatory = $true)] + [string] $Source, + [Parameter(Mandatory = $true)] + [string] $Destination + ) + + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + throw "Archive source '$Source' does not exist." + } + if (Test-Path -LiteralPath $Destination) { + throw "Archive destination '$Destination' already exists." + } + + [IO.Compression.ZipFile]::CreateFromDirectory( + $Source, + $Destination, + [IO.Compression.CompressionLevel]::Optimal, + $true) +} + +$expectedPackages = @( + 'OpenGameAgent.Kernel', + 'OpenGameAgent', + 'OpenGameAgent.Persistence', + 'OpenGameAgent.Providers.OpenAICompatible', + 'OpenGameAgent.Providers.MediaHttp', + 'OpenGameAgent.Client' +) +$nugetRoot = Join-Path $artifactsRoot 'nuget' +foreach ($packageId in $expectedPackages) { + $name = "$packageId.$Version.nupkg" + $source = Join-Path $nugetRoot $name + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Release package '$name' is missing." + } + Copy-Item -LiteralPath $source -Destination (Join-Path $outputRoot $name) +} + +$godotSource = Join-Path $artifactsRoot 'godot\open-game-agent-godot' +$unitySource = Join-Path $artifactsRoot 'unity\com.opengameagent.runtime' +New-DirectoryArchive -Source $godotSource -Destination (Join-Path $outputRoot "OpenGameAgent.Godot-$Version.zip") +New-DirectoryArchive -Source $unitySource -Destination (Join-Path $outputRoot "OpenGameAgent.Unity-$Version.zip") + +$serverSource = Join-Path $artifactsRoot 'server' +$serverBundleName = "OpenGameAgent.Server-$Version-portable" +$serverStage = Join-Path $outputRoot $serverBundleName +if (-not $serverStage.StartsWith($outputRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Server staging path is unsafe.' +} +New-Item -ItemType Directory -Path $serverStage | Out-Null +$serverFiles = @( + 'appsettings.json', + 'OpenGameAgent.Kernel.dll', + 'OpenGameAgent.dll', + 'OpenGameAgent.Persistence.dll', + 'OpenGameAgent.Providers.OpenAICompatible.dll', + 'OpenGameAgent.Server.deps.json', + 'OpenGameAgent.Server.dll', + 'OpenGameAgent.Server.runtimeconfig.json' +) +foreach ($relative in $serverFiles) { + $source = Join-Path $serverSource $relative + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Portable server file '$relative' is missing." + } + Copy-Item -LiteralPath $source -Destination (Join-Path $serverStage $relative) +} +Copy-Item -LiteralPath (Join-Path $repositoryRoot 'LICENSE') -Destination (Join-Path $serverStage 'LICENSE') +Copy-Item -LiteralPath (Join-Path $repositoryRoot 'docs\deployment-and-security.md') -Destination (Join-Path $serverStage 'README.md') +New-DirectoryArchive -Source $serverStage -Destination (Join-Path $outputRoot "$serverBundleName.zip") +Remove-Item -LiteralPath $serverStage -Recurse -Force + +$changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw +$escapedVersion = [regex]::Escape($Version) +$match = [regex]::Match($changelog, "(?ms)^##\s+$escapedVersion\s*\r?\n(?.*?)(?=^##\s+|\z)") +if (-not $match.Success) { + throw "CHANGELOG.md does not contain version '$Version'." +} +$releaseNotes = @( + "# OpenGameAgent v$Version", + '', + $match.Groups['body'].Value.Trim(), + '', + '## Install', + '', + '```bash', + "dotnet add package OpenGameAgent --version $Version", + '```', + '', + 'Use the versioned Godot or Unity archive below for engine integration. The portable server archive runs with `dotnet OpenGameAgent.Server.dll` on a .NET 8 host.', + '', + 'This is an alpha release. Public APIs can change before 1.0.' +) -join [Environment]::NewLine +$releaseNotes | Set-Content -LiteralPath (Join-Path $outputRoot 'RELEASE_NOTES.md') -Encoding utf8NoBOM + +$downloadAssets = Get-ChildItem -LiteralPath $outputRoot -File | + Where-Object Name -ne 'RELEASE_NOTES.md' | + Sort-Object Name +$checksumLines = foreach ($asset in $downloadAssets) { + $hash = (Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($asset.Name)" +} +$checksumLines | Set-Content -LiteralPath (Join-Path $outputRoot 'SHA256SUMS.txt') -Encoding utf8NoBOM + +Write-Output $outputRoot