From 2dd7f5b14a5ab79a12a0de1a76cc5980d7cd45eb Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 04:50:27 +0800 Subject: [PATCH] feat(workspace): add package installation validation --- .gitea/workflows/workspace-validate.yml | 55 +++ Assets/Editor.meta | 8 + Assets/Editor/ShrinkSdkWorkspaceValidation.cs | 190 ++++++++ .../ShrinkSdkWorkspaceValidation.cs.meta | 11 + DESIGN.md | 21 +- README.md | 45 ++ ...nitialize-ShrinkSdkPackageRepositories.ps1 | 429 ++++++++++++++++++ 7 files changed, 751 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/workspace-validate.yml create mode 100644 Assets/Editor.meta create mode 100644 Assets/Editor/ShrinkSdkWorkspaceValidation.cs create mode 100644 Assets/Editor/ShrinkSdkWorkspaceValidation.cs.meta create mode 100644 README.md create mode 100644 Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1 diff --git a/.gitea/workflows/workspace-validate.yml b/.gitea/workflows/workspace-validate.yml new file mode 100644 index 0000000..eb283b8 --- /dev/null +++ b/.gitea/workflows/workspace-validate.yml @@ -0,0 +1,55 @@ +name: Validate ShrinkSDK Workspace + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + unity: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + permissions: + contents: read + env: + UNITY_VERSION: 2022.3.62f3 + steps: + - name: Fetch exact Workspace revision and submodules + shell: bash + run: | + set -euo pipefail + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/Workspace.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + git submodule sync --recursive + git submodule update --init --recursive + module_count="$(git submodule status --recursive | wc -l | tr -d ' ')" + test "$module_count" = "21" + test -z "$(git submodule status --recursive | grep '^-')" + git submodule status --recursive + + - name: Compile Workspace and validate package graph + shell: bash + run: | + set -euo pipefail + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + mkdir -p Artifacts + "$unity_bin" -version | head -n 1 | grep -F "$UNITY_VERSION" + status=0 + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD" \ + -executeMethod ShrinkSDK.WorkspaceValidation.ShrinkSdkWorkspaceValidation.Run \ + -logFile "$PWD/Artifacts/unity-workspace-validation.log" || status=$? + tail -n 240 "$PWD/Artifacts/unity-workspace-validation.log" || true + exit "$status" diff --git a/Assets/Editor.meta b/Assets/Editor.meta new file mode 100644 index 0000000..bbc8cd8 --- /dev/null +++ b/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fa5cd8919ec4f544b89255c3da6b4ba0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/ShrinkSdkWorkspaceValidation.cs b/Assets/Editor/ShrinkSdkWorkspaceValidation.cs new file mode 100644 index 0000000..89bedae --- /dev/null +++ b/Assets/Editor/ShrinkSdkWorkspaceValidation.cs @@ -0,0 +1,190 @@ +#if UNITY_EDITOR +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace ShrinkSDK.WorkspaceValidation +{ + public static class ShrinkSdkWorkspaceValidation + { + private static readonly ExpectedPackage[] ExpectedPackages = + { + new ExpectedPackage("ShrinkApp.Core", "com.cneicy.shrink-app-core", "0.1.1"), + new ExpectedPackage("ShrinkApp.Starter.Basic", "com.cneicy.shrink-app-starter-basic", "0.1.0"), + new ExpectedPackage("ShrinkCommand", "com.cneicy.shrink-command", "0.2.0"), + new ExpectedPackage("ShrinkCommand.Integration.App", "com.cneicy.shrink-command-integration-app", "0.1.0"), + new ExpectedPackage("ShrinkCommand.Integration.EventBus", "com.cneicy.shrink-command-integration-eventbus", "0.1.1"), + new ExpectedPackage("ShrinkCommand.Integration.Network", "com.cneicy.shrink-command-integration-network", "0.1.0"), + new ExpectedPackage("ShrinkContext.AppAdapter", "com.cneicy.shrink-context-app-adapter", "0.1.0"), + new ExpectedPackage("ShrinkContext.Core", "com.cneicy.shrink-context-core", "0.1.0"), + new ExpectedPackage("ShrinkContext.EventBusAdapter", "com.cneicy.shrink-context-eventbus-adapter", "0.1.0"), + new ExpectedPackage("ShrinkDataSaver", "com.cneicy.shrink-datasaver", "2.2.0"), + new ExpectedPackage("ShrinkDataSaver.Integration.App", "com.cneicy.shrink-datasaver-integration-app", "0.1.0"), + new ExpectedPackage("ShrinkDataSaver.Integration.EventBus", "com.cneicy.shrink-datasaver-integration-eventbus", "2.1.0"), + new ExpectedPackage("ShrinkEventBus", "com.cneicy.shrink-eventbus", "2.0.0"), + new ExpectedPackage("ShrinkEventBus.Entities", "com.cneicy.shrink-eventbus-entities", "0.1.0"), + new ExpectedPackage("ShrinkInstaller", "com.cneicy.shrink-installer", "0.1.2"), + new ExpectedPackage("ShrinkModFramework", "com.cneicy.shrink-mod-framework", "0.2.1"), + new ExpectedPackage("ShrinkNetwork", "com.cneicy.shrink-network", "0.2.0"), + new ExpectedPackage("ShrinkNetwork.Integration.App", "com.cneicy.shrink-network-integration-app", "0.1.0"), + new ExpectedPackage("ShrinkNetwork.Integration.EventBus", "com.cneicy.shrink-network-integration-eventbus", "0.1.1"), + new ExpectedPackage("ShrinkShared.CodeGen", "com.cneicy.shrink-shared-codegen", "0.1.0"), + new ExpectedPackage("ShrinkTutorial", "com.cneicy.shrink-tutorial", "0.1.0") + }; + + public static void Run() + { + try + { + var workspaceRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var packages = ReadPackages(workspaceRoot); + ValidateGraph(packages); + Debug.Log($"ShrinkSDK Workspace validation passed: packages={packages.Count}"); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static Dictionary ReadPackages(string workspaceRoot) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var expected in ExpectedPackages) + { + var manifestPath = Path.Combine(workspaceRoot, "Assets", "Modules", expected.Directory, "package.json"); + if (!File.Exists(manifestPath)) + { + throw new FileNotFoundException($"Required package manifest is missing: {expected.Directory}", manifestPath); + } + + var manifest = JObject.Parse(File.ReadAllText(manifestPath)); + var packageName = manifest.Value("name"); + var version = manifest.Value("version"); + if (packageName == null || version == null || packageName.Length == 0 || version.Length == 0) + { + throw new InvalidOperationException($"{expected.Directory} has no valid package name or version."); + } + + if (!string.Equals(packageName, expected.Name, StringComparison.Ordinal) || + !string.Equals(version, expected.Version, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{expected.Directory} must be {expected.Name}@{expected.Version}, found {packageName}@{version}."); + } + + if (result.ContainsKey(packageName)) + { + throw new InvalidOperationException($"Duplicate package manifest name: {packageName}"); + } + + result.Add(packageName, new PackageDefinition(packageName, version, manifest)); + } + + return result; + } + + private static void ValidateGraph(IReadOnlyDictionary packages) + { + var dependencies = new Dictionary>(StringComparer.Ordinal); + foreach (var package in packages.Values) + { + var localDependencies = new HashSet(StringComparer.Ordinal); + var manifestDependencies = package.Manifest["dependencies"] as JObject; + if (manifestDependencies != null) + { + foreach (var property in manifestDependencies.Properties()) + { + if (!packages.TryGetValue(property.Name, out var dependency)) + { + continue; + } + + var expectedVersion = property.Value.Value(); + if (!string.Equals(expectedVersion, dependency.Version, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{package.Name} requires {dependency.Name} {expectedVersion}, but the Workspace provides {dependency.Version}."); + } + + var isIntegrationOrStarter = + package.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 || + package.Name.IndexOf("-starter-", StringComparison.Ordinal) >= 0; + if (dependency.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 && !isIntegrationOrStarter) + { + throw new InvalidOperationException( + $"{package.Name} must not depend on integration package {dependency.Name}."); + } + + localDependencies.Add(dependency.Name); + } + } + + dependencies.Add(package.Name, localDependencies); + } + + var resolved = new HashSet(StringComparer.Ordinal); + var madeProgress = true; + while (madeProgress) + { + madeProgress = false; + foreach (var packageName in dependencies.Keys.OrderBy(name => name, StringComparer.Ordinal)) + { + if (resolved.Contains(packageName) || dependencies[packageName].Any(dependency => !resolved.Contains(dependency))) + { + continue; + } + + resolved.Add(packageName); + madeProgress = true; + } + } + + if (resolved.Count != packages.Count) + { + var blocked = dependencies + .Where(pair => !resolved.Contains(pair.Key)) + .Select(pair => pair.Key + " -> " + string.Join(", ", pair.Value.Where(dependency => !resolved.Contains(dependency)))) + .OrderBy(value => value, StringComparer.Ordinal); + throw new InvalidOperationException("Circular internal package dependencies detected: " + string.Join("; ", blocked)); + } + } + + private sealed class PackageDefinition + { + public PackageDefinition(string name, string version, JObject manifest) + { + Name = name; + Version = version; + Manifest = manifest; + } + + public string Name { get; } + public string Version { get; } + public JObject Manifest { get; } + } + + private readonly struct ExpectedPackage + { + public ExpectedPackage(string directory, string name, string version) + { + Directory = directory; + Name = name; + Version = version; + } + + public string Directory { get; } + public string Name { get; } + public string Version { get; } + } + } +} +#endif diff --git a/Assets/Editor/ShrinkSdkWorkspaceValidation.cs.meta b/Assets/Editor/ShrinkSdkWorkspaceValidation.cs.meta new file mode 100644 index 0000000..5dd8df4 --- /dev/null +++ b/Assets/Editor/ShrinkSdkWorkspaceValidation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 508c3ec513ec5194cb44d97137cf0a85 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/DESIGN.md b/DESIGN.md index a4b8b76..115dfe6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,11 +1,11 @@ # ShrinkSDK 当前架构 -> 当前基线:2026-08-18 +> 当前基线:2026-08-26 > 本文是仓库唯一的当前架构文档。实现与本文冲突时,以源码、`package.json`、asmdef 和自动化验证结果为准,并应在同一变更中修正文档。迁移过程与旧代码地图只保存在 `Docs/Archive/`,不再作为当前设计依据。 ## 1. 项目定位 -ShrinkSDK 是以 Unity Package Manager 包为发布边界的 SDK 单仓库,不是单个游戏项目。仓库直接拥有 `Assets/Modules/` 下全部包源码;模块目录不再是嵌套 Git 仓库或 gitlink。一次干净克隆应能获得完整源码,Unity 只需要恢复外部 UPM 依赖。 +ShrinkSDK 是以 Unity Package Manager 包为发布边界的 SDK Workspace,不是单个游戏项目。`Workspace` 根仓库保留集成工程、场景、跨包验证和文档;`Assets/Modules/` 下每个包是同路径 Git submodule,相邻目录 `.meta` 仍由根仓库追踪,避免 Unity GUID 和既有引用变化。一次干净克隆必须使用递归 submodule 初始化,Unity 只需要恢复外部 UPM 依赖。 它提供四层能力: @@ -14,13 +14,14 @@ ShrinkSDK 是以 Unity Package Manager 包为发布边界的 SDK 单仓库,不 3. 应用与扩展宿主:ShrinkApp、Starter、ModFramework。 4. 边界适配与生成工具:`*.Integration.*`、共享 IL 后处理器、独立服务器生成器。 -当前默认应用主路径是 `ContextLoader`。`ClassicHost` 和旧 installer 只作为兼容面保留,不再承载新架构能力。 +当前默认应用主路径是 `ContextLoader`。`ClassicHost` 仅是既有运行时兼容面;包分发与安装只使用公开 registry、精确 Git 标签或 `Installer` 引导包,不保留旧安装入口。 ## 2. 开发原则 ### 2.1 包是发布边界,asmdef 是编译边界 - 每个 `Assets/Modules//package.json` 都必须能被独立 UPM 消费。 +- 公开发布先走 `https://git.crash.work/api/packages/ShrinkSDK/npm/` 的 `com.cneicy` scoped registry;Git 安装只能固定到对应 `vX.Y.Z` 标签,禁止浮动分支。 - 主包不反向依赖集成包;跨模块行为放入 `*.Integration.*` 或 Context adapter。 - asmdef 只声明实际编译依赖,不依靠根工程中偶然存在的程序集。 - 内部包依赖版本必须等于被依赖包自身的 `version`,不接受“根工程能编译所以先放着”的漂移。 @@ -63,14 +64,16 @@ ShrinkSDK 是以 Unity Package Manager 包为发布边界的 SDK 单仓库,不 ## 3. 仓库结构 ```text -ShrinkSDK/ -|-- Assets/Modules/ 20 个根仓库直接跟踪的 UPM 包 +ShrinkSDK Workspace/ +|-- Assets/Modules/ 21 个同路径 Git submodule UPM 包 +|-- Assets/Modules/*.meta 根仓库追踪的 Unity 目录 GUID |-- Assets/Scenes/ 示例与验收场景 |-- Assets/Resources/ 当前应用配置与组合 Profile |-- GeneratedServers/ 独立 .NET 宿主、生成合同与烟测工程 |-- GeneratedModSdk/ Mod SDK 导出物 |-- Packages/ 根 Unity 工程依赖 |-- Tools/UpmConsumerValidation/ 干净 UPM 消费工程验证 +|-- Tools/RepositoryMigration/ 子模块、发布仓库与独立宿主初始化脚本 |-- Docs/Archive/ 已完成迁移与过期地图,仅供追溯 |-- DESIGN.md 唯一当前架构文档 |-- NETWORK_PITFALLS.md 网络实现经验记录,不是架构基线 @@ -86,7 +89,7 @@ ShrinkSDK/ | `com.cneicy.shrink-datasaver` | 2.2.0 | 多槽位存档、设置、迁移、加密、原子写入与备份 | | `com.cneicy.shrink-command` | 0.2.0 | 路径式命令、权限与同步/异步执行 | | `com.cneicy.shrink-network` | 0.2.0 | 消息、RPC、权限、诊断、TCP/KCP/Loopback 与服务器生成 | -| `com.cneicy.shrink-mod-framework` | 0.2.0 | 模组发现、依赖、可逆生命周期、命名空间内容覆盖、外部 DLL revision 与 Harmony lease | +| `com.cneicy.shrink-mod-framework` | 0.2.1 | 模组发现、依赖、可逆生命周期、命名空间内容覆盖、外部 DLL revision 与 Harmony lease | | `com.cneicy.shrink-tutorial` | 0.1.0 | 数据驱动引导、遮罩、锚点、触发与持久化 | | `com.cneicy.shrink-context-core` | 0.1.0 | 可逆效应、coeffect、fiber、声明式 loader 与诊断 | | `com.cneicy.shrink-app-core` | 0.1.1 | App 设置、服务门面、ClassicHost 兼容面与宿主协议 | @@ -101,6 +104,7 @@ ShrinkSDK/ | `com.cneicy.shrink-network-integration-eventbus` | 0.1.1 | 网络事件广播、裁决结果与 delta 去重 | | `com.cneicy.shrink-network-integration-app` | 0.1.0 | Network App installer/原生 Context 组件 | | `com.cneicy.shrink-shared-codegen` | 0.1.0 | App、Command、Network 共用的 Editor-only IL 后处理注册表生成器 | +| `com.cneicy.shrink-installer` | 0.1.2 | 安全合并公开 registry、显示诊断并固定版本安装 Starter 或选定模块的 Editor 引导包 | `ShrinkShared.CodeGen` 不反向引用业务 asmdef,只按程序集名与类型全名读取 Cecil 元数据,因此业务包可以依赖它而不形成包循环。 @@ -221,13 +225,13 @@ Mono 中已加载程序集不能真正卸载。系统只回滚组件实例与效 1. 内部包图检查:全部 `com.cneicy.*` 依赖版本一致、无循环,普通主包不反向依赖 Integration 包。 2. Unity 编译:目标 asmdef 和根项目无编译错误。 3. EditMode 测试:功能测试、Context 生命周期、语义扫描和模板覆写保护。 -4. 真实 UPM 消费:`Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1` 创建仓库外形态的临时 Unity 工程,通过 `file:` 安装全部 20 个包,启用 testables,验证包注册、程序集加载并运行 EditMode 测试。 +4. 真实 UPM 消费:`Tools/UpmConsumerValidation/Validate-UpmConsumer.ps1` 在递归 submodule 初始化后的 Workspace 中创建仓库外形态的临时 Unity 工程,通过 `file:` 安装全部 21 个包,启用 testables,验证包注册、程序集加载并运行 EditMode 测试。发布后还必须分别验证 registry、精确 Git URL 和 Installer 三种空白消费者工程路径。 5. 独立宿主:生成工程与 RuntimeSmoke 按变更范围构建或运行。 6. 涉及真实生命周期时,仍需在目标场景执行 Play Mode 验收;源码检查和 EditMode 不能替代该路径。 ## 10. 已知边界 -- ClassicHost、旧 installer 和静态门面仍是兼容层,暂未删除。 +- ClassicHost 和静态门面仍是运行时兼容层;旧 installer 不再是受支持的包安装入口。 - Context 的 intercept 不是进程级沙箱;外部程序集 revision 不可从 Mono 卸载。 - 某些第三方或领域副作用只能补偿,不能保证物理撤回。 - 服务器生成器输出可编译、可注册的合同和扩展点,不推断完整业务规则。 @@ -249,6 +253,7 @@ Mono 中已加载程序集不能真正卸载。系统只回滚组件实例与效 - 当前架构只更新本文。 - 包级使用方式和 API 示例放在各包 README。 +- 包源码、标签、独立开发宿主与包级 CI 位于 `https://git.crash.work/ShrinkSDK/`;发布版本只能由与 `package.json.version` 一致的 `vX.Y.Z` 标签产生。 - `NETWORK_PITFALLS.md` 记录实现经验,不描述当前模块清单。 - `Docs/Archive/CORDIS_MIGRATION.completed.md` 保存迁移论证、阶段记录和历史验收数据。 - `Docs/Archive/codebase-map-2026-05-23/` 保存迁移前代码地图,其中关于 Git 状态、模块规模和测试覆盖的描述均已过期。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..236bb92 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# ShrinkSDK Workspace + +ShrinkSDK 的 Workspace 用于跨包集成、场景验证、生成器验证和 SDK 文档。每个 `Assets/Modules/` 是独立公开仓库的 Git submodule;根仓库保留相邻 `.meta`,因此不要删除或重新生成这些文件。 + +## 获取 Workspace + +```text +git clone --recurse-submodules https://git.crash.work/ShrinkSDK/Workspace.git +cd Workspace +git submodule update --init --recursive +``` + +所有模块都可以独立打开其 `Development~/UnityProject`。根 Workspace 仅用于跨包组合与验收。 + +## 安装 SDK + +首选公开 Gitea NPM/UPM registry,在消费者项目的 `Packages/manifest.json` 中保留既有配置并加入: + +```json +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": ["com.cneicy"] + } + ] +} +``` + +然后按精确版本添加包,例如 `com.cneicy.shrink-app-starter-basic: 0.1.0`。Git 备用安装同样必须固定标签,例如: + +```text +https://git.crash.work/ShrinkSDK/ShrinkEventBus.git#v2.0.0 +``` + +也可先通过固定标签安装 Editor 引导包: + +```text +https://git.crash.work/ShrinkSDK/Installer.git#v0.1.2 +``` + +随后在 Unity 打开 `ShrinkSDK/Packages`。安装器只合并 `com.cneicy` registry 并安装内置目录中的固定版本,不写认证信息、不复制模板到 `Assets`。 + +发布、子模块迁移和独立开发宿主生成规则位于 [Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1](Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1)。架构约束与跨包验证要求见 [DESIGN.md](DESIGN.md)。 diff --git a/Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1 b/Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1 new file mode 100644 index 0000000..de01086 --- /dev/null +++ b/Tools/RepositoryMigration/Initialize-ShrinkSdkPackageRepositories.ps1 @@ -0,0 +1,429 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$StagingRoot, + + [string]$SourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +) + +$ErrorActionPreference = 'Stop' + +$RegistryUrl = 'https://git.crash.work/api/packages/ShrinkSDK/npm/' +$RegistryScope = 'com.cneicy' +$RepositoryBaseUrl = 'https://git.crash.work/ShrinkSDK' +$UnityVersion = '2022.3.62f3' +$UnityRevision = '96770f904ca7' + +$PackageDirectories = @( + 'ShrinkApp.Core', + 'ShrinkApp.Starter.Basic', + 'ShrinkCommand', + 'ShrinkCommand.Integration.App', + 'ShrinkCommand.Integration.EventBus', + 'ShrinkCommand.Integration.Network', + 'ShrinkContext.AppAdapter', + 'ShrinkContext.Core', + 'ShrinkContext.EventBusAdapter', + 'ShrinkDataSaver', + 'ShrinkDataSaver.Integration.App', + 'ShrinkDataSaver.Integration.EventBus', + 'ShrinkEventBus', + 'ShrinkEventBus.Entities', + 'ShrinkInstaller', + 'ShrinkModFramework', + 'ShrinkNetwork', + 'ShrinkNetwork.Integration.App', + 'ShrinkNetwork.Integration.EventBus', + 'ShrinkShared.CodeGen', + 'ShrinkTutorial' +) + +function Set-JsonProperty { + param( + [Parameter(Mandatory)] [object]$Target, + [Parameter(Mandatory)] [string]$Name, + [Parameter(Mandatory)] [object]$Value + ) + + $property = $Target.PSObject.Properties[$Name] + if ($null -eq $property) { + $Target | Add-Member -MemberType NoteProperty -Name $Name -Value $Value + return + } + + $property.Value = $Value +} + +function Write-Utf8File { + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [AllowEmptyString()] [string]$Content + ) + + $parent = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) +} + +function Update-PackageMetadata { + param( + [Parameter(Mandatory)] [string]$PackageRoot, + [Parameter(Mandatory)] [string]$RepositoryName + ) + + $packagePath = Join-Path $PackageRoot 'package.json' + $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json + Set-JsonProperty -Target $package -Name 'documentationUrl' -Value "$RepositoryBaseUrl/$RepositoryName" + + $changelogPath = Join-Path $PackageRoot 'CHANGELOG.md' + if (Test-Path -LiteralPath $changelogPath) { + Set-JsonProperty -Target $package -Name 'changelogUrl' -Value "$RepositoryBaseUrl/$RepositoryName/src/branch/main/CHANGELOG.md" + } + + $licensePath = Join-Path $PackageRoot 'LICENSE' + if (Test-Path -LiteralPath $licensePath) { + Set-JsonProperty -Target $package -Name 'licensesUrl' -Value "$RepositoryBaseUrl/$RepositoryName/src/branch/main/LICENSE" + } + + if ($null -eq $package.author) { + $package | Add-Member -MemberType NoteProperty -Name 'author' -Value ([PSCustomObject]@{}) + } + + Set-JsonProperty -Target $package.author -Name 'name' -Value 'cneicy' + Set-JsonProperty -Target $package.author -Name 'url' -Value $RepositoryBaseUrl + Write-Utf8File -Path $packagePath -Content (($package | ConvertTo-Json -Depth 16) + [Environment]::NewLine) + + Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Filter '*.md' | ForEach-Object { + $content = Get-Content -LiteralPath $_.FullName -Raw + $updated = $content.Replace('https://github.com/cneicy/', "$RepositoryBaseUrl/") + if ($updated -ne $content) { + Write-Utf8File -Path $_.FullName -Content $updated + } + } +} + +function Write-PublishFiles { + param( + [Parameter(Mandatory)] [string]$PackageRoot, + [Parameter(Mandatory)] [string]$PackageName, + [Parameter(Mandatory)] [string]$RepositoryName + ) + + $npmIgnore = @' +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user +'@ + Write-Utf8File -Path (Join-Path $PackageRoot '.npmignore') -Content ($npmIgnore + [Environment]::NewLine) + + $repositoryGitIgnore = @' +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user +'@ + $repositoryGitIgnorePath = Join-Path $PackageRoot '.gitignore' + if (Test-Path -LiteralPath $repositoryGitIgnorePath) { + $repositoryGitIgnore = (Get-Content -LiteralPath $repositoryGitIgnorePath -Raw).TrimEnd() + [Environment]::NewLine + $repositoryGitIgnore + } + Write-Utf8File -Path $repositoryGitIgnorePath -Content ($repositoryGitIgnore + [Environment]::NewLine) + + $publishWorkflow = @' +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch exact tagged release archive + env: + GITEA_REF: ${{ gitea.ref }} + shell: bash + run: | + set -eu + tag="${GITEA_REF#refs/tags/}" + case "$tag" in + v[0-9]*) ;; + *) echo "Expected a version tag ref, got: $GITEA_REF" >&2; exit 1 ;; + esac + export SHRINKSDK_ARCHIVE_URL="https://git.crash.work/ShrinkSDK/__REPOSITORY_NAME__/archive/${tag}.tar.gz" + node --input-type=module <<'NODE' + import { writeFile } from 'node:fs/promises'; + + const response = await fetch(process.env.SHRINKSDK_ARCHIVE_URL); + if (!response.ok) { + throw new Error(`Release archive download failed: ${response.status} ${response.statusText}`); + } + + await writeFile('release.tar.gz', new Uint8Array(await response.arrayBuffer())); + NODE + mkdir release + tar -xzf release.tar.gz --strip-components=1 -C release + rm -f release.tar.gz + printf '%s' "$tag" > release/.shrink-sdk-release-tag + + - name: Validate immutable release version + shell: bash + run: | + set -eu + cd release + tag="$(cat .shrink-sdk-release-tag)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + cd release + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ +'@ + $publishWorkflow = $publishWorkflow.Replace('__REPOSITORY_NAME__', $RepositoryName) + Write-Utf8File -Path (Join-Path $PackageRoot '.gitea/workflows/publish.yml') -Content $publishWorkflow + + $verifyWorkflow = @' +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/__REPOSITORY_NAME__.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" +'@ + $verifyWorkflow = $verifyWorkflow.Replace('__REPOSITORY_NAME__', $RepositoryName) + Write-Utf8File -Path (Join-Path $PackageRoot '.gitea/workflows/unity-verify.yml') -Content $verifyWorkflow + + $manifest = [ordered]@{ + scopedRegistries = @( + [ordered]@{ + name = 'ShrinkSDK' + url = $RegistryUrl + scopes = @($RegistryScope) + } + ) + dependencies = [ordered]@{ + 'com.unity.test-framework' = '1.1.33' + $PackageName = 'file:../../..' + } + } + $developmentRoot = Join-Path $PackageRoot 'Development~/UnityProject' + Write-Utf8File -Path (Join-Path $developmentRoot 'Packages/manifest.json') -Content (($manifest | ConvertTo-Json -Depth 12) + [Environment]::NewLine) + Write-Utf8File -Path (Join-Path $developmentRoot 'ProjectSettings/ProjectVersion.txt') -Content "m_EditorVersion: $UnityVersion`nm_EditorVersionWithRevision: $UnityVersion ($UnityRevision)`n" + Write-Utf8File -Path (Join-Path $developmentRoot 'Assets/.gitkeep') -Content '' + Write-Utf8File -Path (Join-Path $developmentRoot '.gitignore') -Content @' +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ +'@ +} + +function Add-EventBusDotNetTools { + param( + [Parameter(Mandatory)] [string]$PackageRoot, + [Parameter(Mandatory)] [string]$RepositorySourceRoot + ) + + $source = Join-Path $RepositorySourceRoot 'DotNet' + $destination = Join-Path $PackageRoot 'Tools~/DotNet' + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force + + $coreProjectPath = Join-Path $destination 'ShrinkEventBus.Core/ShrinkEventBus.Core.csproj' + $coreProject = Get-Content -LiteralPath $coreProjectPath -Raw + $coreProject = $coreProject.Replace('..\..\Assets\Modules\ShrinkEventBus\Runtime\', '..\..\..\Runtime\') + Write-Utf8File -Path $coreProjectPath -Content $coreProject + + $readmePath = Join-Path $destination 'README.md' + $readme = Get-Content -LiteralPath $readmePath -Raw + $readme = $readme.Replace('DotNet/', 'Tools~/DotNet/') + Write-Utf8File -Path $readmePath -Content $readme +} + +function Add-ModFrameworkFixtureBuilder { + param( + [Parameter(Mandatory)] [string]$PackageRoot, + [Parameter(Mandatory)] [string]$RepositorySourceRoot + ) + + $source = Join-Path $RepositorySourceRoot 'Tools/ShrinkModFixtureBuilder' + $destination = Join-Path $PackageRoot 'Tools~/FixtureBuilder' + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force + + $fixtureProject = @' + + + netstandard2.1 + 9.0 + enable + false + ExternalFixture.Mod + ExternalFixture + + + + + + $(RuntimeAssemblyPath) + false + + + +'@ + Write-Utf8File -Path (Join-Path $destination 'ShrinkModFixtureBuilder.csproj') -Content $fixtureProject + + $rebuildScript = @' +[CmdletBinding()] +param( + [string]$UnityProjectRoot +) + +$ErrorActionPreference = 'Stop' + +$packageRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../..')).Path +if (-not $UnityProjectRoot) { + $UnityProjectRoot = Join-Path $packageRoot 'Development~/UnityProject' +} + +$resolvedUnityProjectRoot = (Resolve-Path -LiteralPath $UnityProjectRoot).Path +$fixtureProject = Join-Path $PSScriptRoot 'ShrinkModFixtureBuilder.csproj' +$fixtureOutputRoot = Join-Path $resolvedUnityProjectRoot 'Temp/ShrinkModFixtureBuilder' +$fixtureTargetRoot = Join-Path $packageRoot 'Tests/Fixtures' +$runtimeAssemblyPath = Join-Path $resolvedUnityProjectRoot 'Temp/Bin/Debug/ShrinkModFramework.Runtime/ShrinkModFramework.Runtime.dll' + +if (-not (Test-Path -LiteralPath $runtimeAssemblyPath)) { + throw "ShrinkModFramework.Runtime.dll was not found. Compile the Unity development host first: $resolvedUnityProjectRoot" +} + +foreach ($variant in 'V1', 'V2', 'V3') { + $outputDirectory = Join-Path $fixtureOutputRoot $variant + $intermediateDirectory = Join-Path $fixtureOutputRoot "obj/$variant/" + dotnet build $fixtureProject /m:1 --nologo ` + -p:FixtureVariant=$variant ` + -p:RuntimeAssemblyPath=$runtimeAssemblyPath ` + -p:BaseIntermediateOutputPath=$intermediateDirectory ` + -p:OutputPath=$outputDirectory + if ($LASTEXITCODE -ne 0) { + throw "External fixture $variant build failed with exit code $LASTEXITCODE." + } + + $source = Join-Path $outputDirectory 'ExternalFixture.Mod.dll' + $targetName = switch ($variant) { + 'V1' { 'ExternalFixture.Mod.V1.dll.bytes' } + 'V2' { 'ExternalFixture.Mod.V2.Failing.dll.bytes' } + 'V3' { 'ExternalFixture.Mod.V3.dll.bytes' } + } + Copy-Item -LiteralPath $source -Destination (Join-Path $fixtureTargetRoot $targetName) -Force +} + +Write-Host 'ShrinkModFramework external fixture DLLs rebuilt.' +'@ + Write-Utf8File -Path (Join-Path $destination 'Rebuild-ShrinkModFixtures.ps1') -Content $rebuildScript +} + +$resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path +if (Test-Path -LiteralPath $StagingRoot) { + throw "StagingRoot already exists: $StagingRoot" +} + +New-Item -ItemType Directory -Path $StagingRoot -Force | Out-Null +$releaseMap = [System.Collections.Generic.List[object]]::new() + +foreach ($directory in $PackageDirectories) { + $source = Join-Path $resolvedSourceRoot "Assets/Modules/$directory" + if (-not (Test-Path -LiteralPath $source)) { + throw "Package source was not found: $source" + } + + $destination = Join-Path $StagingRoot $directory + Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force + $repositoryName = if ($directory -eq 'ShrinkInstaller') { 'Installer' } else { $directory } + Update-PackageMetadata -PackageRoot $destination -RepositoryName $repositoryName + + $package = Get-Content -LiteralPath (Join-Path $destination 'package.json') -Raw | ConvertFrom-Json + Write-PublishFiles -PackageRoot $destination -PackageName $package.name -RepositoryName $repositoryName + + if ($directory -eq 'ShrinkEventBus') { + Add-EventBusDotNetTools -PackageRoot $destination -RepositorySourceRoot $resolvedSourceRoot + } + + if ($directory -eq 'ShrinkModFramework') { + Add-ModFrameworkFixtureBuilder -PackageRoot $destination -RepositorySourceRoot $resolvedSourceRoot + } + + $releaseMap.Add([PSCustomObject]@{ + directory = $directory + repositoryName = $repositoryName + package = $package.name + version = $package.version + repository = "$RepositoryBaseUrl/$repositoryName.git" + }) +} + +Write-Utf8File -Path (Join-Path $StagingRoot 'package-map.json') -Content (($releaseMap | ConvertTo-Json -Depth 8) + [Environment]::NewLine) +Write-Host "Prepared $($releaseMap.Count) package repositories in $StagingRoot"