diff --git a/.github/workflows/sync-azure-storage.yml b/.github/workflows/sync-azure-storage.yml index c653a6e..d2305d9 100644 --- a/.github/workflows/sync-azure-storage.yml +++ b/.github/workflows/sync-azure-storage.yml @@ -2,6 +2,9 @@ name: Sync Release to Azure Storage # This workflow is called by build.yml after a release is created. on: + push: + branches: + - syncToAzure workflow_call: inputs: release_tag: diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 64cd88b..3c7c03c 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -119,6 +119,10 @@ "description": "Azure upload retries", "format": "int32" }, + "ChannelMapping": { + "type": "string", + "description": "Custom channel mapping (JSON format)" + }, "FeishuWebhookUrl": { "type": "string", "description": "Feishu webhook URL for notifications", diff --git a/nukeBuild/Adapters/AzureBlobAdapter.cs b/nukeBuild/Adapters/AzureBlobAdapter.cs index 8c9ef8c..6b039cf 100644 --- a/nukeBuild/Adapters/AzureBlobAdapter.cs +++ b/nukeBuild/Adapters/AzureBlobAdapter.cs @@ -2,6 +2,8 @@ using Azure.Storage.Blobs.Models; using AzureStorage; using System.Text.Json; +using System.Security.Cryptography; +using Utils; namespace Adapters; @@ -12,10 +14,34 @@ namespace Adapters; public class AzureBlobAdapter : IAzureBlobAdapter { private readonly AbsolutePath _rootDirectory; + private Dictionary _customChannelMapping; - public AzureBlobAdapter(AbsolutePath rootDirectory) + public AzureBlobAdapter(AbsolutePath rootDirectory, string channelMappingJson = "") { _rootDirectory = rootDirectory; + _customChannelMapping = ParseChannelMapping(channelMappingJson); + } + + /// + /// Parses custom channel mapping from JSON string + /// + /// JSON string mapping version patterns to channels + /// Dictionary of version patterns to channel names + private static Dictionary ParseChannelMapping(string channelMappingJson) + { + if (string.IsNullOrWhiteSpace(channelMappingJson)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var mapping = JsonSerializer.Deserialize>(channelMappingJson); + return mapping ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch (JsonException) + { + Log.Warning("Invalid channel mapping JSON, using default rules"); + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } } public async Task ValidateSasUrlAsync(string sasUrl) @@ -75,13 +101,41 @@ public async Task UploadArtifactsAsync(List file : $"{versionPrefix}{fileName}"; var blobClient = containerClient.GetBlobClient(blobName); - Log.Information("Uploading: {File} -> {Container}/{Blob}", fileName, options.ContainerName, blobName); - await using var stream = File.OpenRead(filePath); - await blobClient.UploadAsync(stream, overwrite: true); - var blobUrl = blobClient.Uri.ToString(); - result.UploadedBlobs.Add(blobUrl); - Log.Information("Upload successful: {Url}", blobUrl); + // Check if blob exists and compare hashes to skip unnecessary uploads + bool shouldUpload = true; + if (await blobClient.ExistsAsync()) + { + var properties = await blobClient.GetPropertiesAsync(); + var remoteHash = properties.Value.ContentHash; + + // Calculate local file hash + byte[] localHash; + await using (var stream = File.OpenRead(filePath)) + using (var md5 = MD5.Create()) + { + localHash = await md5.ComputeHashAsync(stream); + } + + // Compare hashes + if (remoteHash != null && localHash.SequenceEqual(remoteHash)) + { + Log.Information("Skipping {File} (unchanged, hash: {Hash})", fileName, Convert.ToHexString(localHash)[..8]); + shouldUpload = false; + result.SkippedBlobs.Add(blobClient.Uri.ToString()); + } + } + + if (shouldUpload) + { + Log.Information("Uploading: {File} -> {Container}/{Blob}", fileName, options.ContainerName, blobName); + + await using var stream = File.OpenRead(filePath); + await blobClient.UploadAsync(stream, overwrite: true); + var blobUrl = blobClient.Uri.ToString(); + result.UploadedBlobs.Add(blobUrl); + Log.Information("Upload successful: {Url}", blobUrl); + } } result.Success = true; @@ -125,7 +179,8 @@ public async Task GenerateIndexOnlyAsync(AzureBlobPublishOptions options var jsonOptions = new JsonSerializerOptions { - WriteIndented = !minify + WriteIndented = !minify, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var jsonContent = JsonSerializer.Serialize(indexData, jsonOptions); @@ -321,23 +376,28 @@ public async Task GenerateIndexFromBlobsAsync(AzureBlobPublishOptions op }) .ToList(); - return new + return new VersionGroup { - version = kv.Key, - files = versionFiles + Version = kv.Key, + Files = versionFiles.Cast().ToList() }; }) .ToList(); + // Build channels object + var channelsData = BuildChannelsObject(versionList); + var indexData = new { updatedAt = DateTime.UtcNow.ToString("o"), - versions = versionList + versions = versionList, + channels = channelsData }; var jsonOptions = new JsonSerializerOptions { - WriteIndented = !minify + WriteIndented = !minify, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var jsonContent = JsonSerializer.Serialize(indexData, jsonOptions); @@ -345,11 +405,14 @@ public async Task GenerateIndexFromBlobsAsync(AzureBlobPublishOptions op await File.WriteAllTextAsync(outputPath, jsonContent); var versionCount = versionList.Count; - var totalFiles = versionList.Sum(v => v.files.Count); + var totalFiles = versionList.Sum(v => v.Files.Count); Log.Information("✅ Index.json generated at: {Path}", outputPath); Log.Information(" Versions: {Count}", versionCount); Log.Information(" Total files: {Count}", totalFiles); + Log.Information("=== index.json Content ==="); + Log.Information(jsonContent); + Log.Information("=== End of index.json ==="); return jsonContent; } @@ -359,6 +422,126 @@ public async Task GenerateIndexFromBlobsAsync(AzureBlobPublishOptions op return string.Empty; } } + + /// + /// Extracts channel name from version string + /// - No dash (-) = stable (正式版), e.g., "1.0.0", "2.3.1" + /// - With dash (-) = check prerelease identifier for channel + /// + /// Version string (e.g., "1.0.0", "0.1.0-beta.11") + /// Channel name as string + private string ExtractChannelFromVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return "beta"; + + // Remove 'v' prefix if present + version = version.TrimStart('v', 'V'); + + // Check custom channel mapping first + foreach (var (pattern, channel) in _customChannelMapping) + { + if (version.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + return channel; + } + + // Check for dash (-) to determine if it's a stable release + var dashIndex = version.IndexOf('-'); + if (dashIndex <= 0) + { + // No prerelease identifier = stable (正式版) + return "stable"; + } + + // Has prerelease identifier - determine channel + var prerelease = version.Substring(dashIndex + 1).ToLowerInvariant(); + + if (prerelease.StartsWith("beta.") || prerelease.StartsWith("beta")) + return "beta"; + if (prerelease.StartsWith("canary.") || prerelease.StartsWith("canary")) + return "canary"; + if (prerelease.StartsWith("alpha.") || prerelease.StartsWith("alpha")) + return "alpha"; + if (prerelease.StartsWith("dev.") || prerelease.StartsWith("dev")) + return "dev"; + if (prerelease.StartsWith("preview.") || prerelease.StartsWith("preview")) + return "preview"; + if (prerelease.StartsWith("rc.") || prerelease.StartsWith("rc")) + return "preview"; + + // Other prerelease types default to preview + return "preview"; + } + + /// + /// Groups versions by their channel + /// + /// List of version groups + /// Dictionary mapping channel names to list of version strings + private Dictionary> GroupVersionsByChannel(List versions) + { + var channelGroups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var version in versions) + { + var channel = ExtractChannelFromVersion(version.Version); + + if (!channelGroups.ContainsKey(channel)) + { + channelGroups[channel] = new List(); + } + + channelGroups[channel].Add(version.Version); + } + + return channelGroups; + } + + /// + /// Builds the channels object for index.json + /// Contains latest version and versions array for each channel + /// + /// List of version groups + /// Dictionary mapping channel names to channel information + private Dictionary BuildChannelsObject(List versions) + { + var channelGroups = GroupVersionsByChannel(versions); + var channelsData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (channelName, versionStrings) in channelGroups) + { + // Parse versions and find the latest using Semver precedence comparison + Semver.SemVersion latestVersion = null; + string latestVersionString = null; + + foreach (var versionStr in versionStrings) + { + if (SemverExtensions.TryParseVersion(versionStr, out var semver)) + { + if (latestVersion == null || + Semver.SemVersion.ComparePrecedence(semver, latestVersion) > 0) + { + latestVersion = semver; + latestVersionString = versionStr; + } + } + } + + // Fallback to first version string if no valid Semver found + if (latestVersionString == null && versionStrings.Count > 0) + { + latestVersionString = versionStrings[0]; + } + + channelsData[channelName] = new ChannelInfo + { + Latest = latestVersionString ?? "", + Versions = versionStrings + }; + } + + return channelsData; + } } /// @@ -370,3 +553,21 @@ public class AzureBlobInfo public long Size { get; init; } public DateTime LastModified { get; init; } } + +/// +/// Version group for index.json generation +/// +public class VersionGroup +{ + public required string Version { get; init; } + public List Files { get; init; } = new(); +} + +/// +/// Channel information for index.json +/// +public class ChannelInfo +{ + public required string Latest { get; init; } + public List Versions { get; init; } = new(); +} diff --git a/nukeBuild/AzureStorageConfiguration.cs b/nukeBuild/AzureStorageConfiguration.cs index 7d8be62..e0f2913 100644 --- a/nukeBuild/AzureStorageConfiguration.cs +++ b/nukeBuild/AzureStorageConfiguration.cs @@ -12,6 +12,7 @@ public class AzureBlobPublishResult { public bool Success { get; set; } public List UploadedBlobs { get; set; } = new(); + public List SkippedBlobs { get; set; } = new(); public string ErrorMessage { get; set; } = string.Empty; public List Warnings { get; set; } = new(); } diff --git a/nukeBuild/Build.AzureStorage.cs b/nukeBuild/Build.AzureStorage.cs index 28d6d33..580db1c 100644 --- a/nukeBuild/Build.AzureStorage.cs +++ b/nukeBuild/Build.AzureStorage.cs @@ -139,7 +139,7 @@ private async Task ExecutePublishToAzureBlob() { Log.Information("=== 步骤 2: 上传到 Azure Blob ==="); - var adapter = new AzureBlobAdapter(RootDirectory); + var adapter = new AzureBlobAdapter(RootDirectory, ChannelMapping); if (!await adapter.ValidateSasUrlAsync(AzureBlobSasUrl)) { @@ -164,6 +164,10 @@ private async Task ExecutePublishToAzureBlob() Log.Information("✅ 构建产物已上传"); Log.Information(" 上传文件数: {Count}", result.UploadedBlobs.Count); + if (result.SkippedBlobs.Count > 0) + { + Log.Information(" 跳过文件数: {Count} (已存在且哈希一致)", result.SkippedBlobs.Count); + } } else { @@ -174,7 +178,7 @@ private async Task ExecutePublishToAzureBlob() { Log.Information("=== 步骤 3: 生成并上传 index.json ==="); - var adapter = new AzureBlobAdapter(RootDirectory); + var adapter = new AzureBlobAdapter(RootDirectory, ChannelMapping); if (!await adapter.ValidateSasUrlAsync(AzureBlobSasUrl)) { diff --git a/nukeBuild/Build.cs b/nukeBuild/Build.cs index 6f198e0..c039ef7 100644 --- a/nukeBuild/Build.cs +++ b/nukeBuild/Build.cs @@ -58,6 +58,9 @@ partial class Build : NukeBuild [Parameter("Release channel (stable, beta, dev)")] readonly string ReleaseChannel = "beta"; + [Parameter("Custom channel mapping (JSON format)")] + readonly string ChannelMapping = ""; + [Parameter("Feishu webhook URL for notifications")] [Secret] readonly string FeishuWebhookUrl = ""; diff --git a/openspec/changes/archive/2026-02-15-indexjson-channel-support/proposal.md b/openspec/changes/archive/2026-02-15-indexjson-channel-support/proposal.md new file mode 100644 index 0000000..e045735 --- /dev/null +++ b/openspec/changes/archive/2026-02-15-indexjson-channel-support/proposal.md @@ -0,0 +1,128 @@ +# indexJSON 生成添加 channel 支持 + +## 概述 + +为 HagiCode Desktop 的 Nuke 构建系统添加 channel(发布渠道)支持,使其在生成 indexJSON 时能够包含 `channels` 字段,用于区分不同发布渠道(如 beta、stable、canary)的版本。 + +## 背景 + +当前 HagiCode Desktop 项目的 Nuke 构建系统在生成 indexJSON 时不支持 channel 功能。参考文件 `/home/newbe36524/repos/newbe36524/pcode/artifacts/azure-index.json` 展示了支持 channel 的数据结构。 + +**重要**:此提案关注的是**生成** indexJSON 的构建系统,而非**读取** indexJSON 的应用逻辑(后者已实现)。 + +## 问题 + +### 当前实现分析 + +**GenerateIndexOnlyAsync** (AzureBlobAdapter.cs:108-143): +- 生成简单的 index 结构,只有 `version`、`channel`、`createdAt`、`files` +- `channel` 字段是单个字符串值,不是对象结构 +- 无法支持多渠道版本管理 + +**GenerateIndexFromBlobsAsync** (AzureBlobAdapter.cs:278-361): +- 从 Azure Blob Storage 列出所有文件 +- 按版本前缀分组(如 "0.1.0-beta.11") +- 生成 `versions` 数组,包含版本和文件信息 +- **缺少** `channels` 对象结构 + +### 期望的数据结构 + +```json +{ + "updatedAt": "2026-02-15T05:45:05.2931068Z", + "versions": [ + { + "version": "0.1.0-beta.11", + "files": ["hagicode-0.1.0-beta.11-linux-x64-nort.zip", ...], + "assets": [...] + } + ], + "channels": { + "beta": { + "latest": "0.1.0-beta.11", + "versions": ["0.1.0-beta.11", "0.1.0-beta.10", ...] + }, + "stable": { + "latest": "1.0.0", + "versions": ["1.0.0", "0.9.0", ...] + } + } +} +``` + +## 解决方案 + +### 核心策略 + +1. **版本到渠道映射**:根据版本字符串确定渠道(如 beta、stable、canary) +2. **channels 对象生成**:为每个渠道创建包含 latest 和 versions 的对象 +3. **构建配置扩展**:在 Nuke 配置中添加渠道定义和映射规则 + +### 实现要点 + +1. **版本解析逻辑**: + - 从版本字符串(如 "0.1.0-beta.11")提取渠道标识 + - 支持常见的渠道命名约定:beta、stable、canary、alpha、dev + - 允许自定义渠道映射规则 + +2. **channels 对象构建**: + - 按渠道分组所有版本 + - 为每个渠道选择 latest 版本(基于语义化版本排序) + - 生成 versions 数组(包含该渠道所有版本) + +3. **Nuke 配置参数**: + - 利用现有的 `ReleaseChannel` 参数(Build.cs:59) + - 添加可选的渠道映射配置 + - 支持多渠道同时生成 + +4. **向后兼容性**: + - 保持现有 index 结构(updatedAt、versions) + - channels 字段为可选添加 + - 确保旧版本客户端仍能正常工作 + +## 影响范围 + +### 需要修改的文件 + +- **nukeBuild/Adapters/AzureBlobAdapter.cs** + - `GenerateIndexFromBlobsAsync` 方法:添加 channels 对象生成逻辑 + - 新增版本到渠道映射方法 + - 新增渠道对象构建方法 + +- **nukeBuild/Build.cs** + - 使用现有的 `ReleaseChannel` 参数 + - 可能需要添加渠道配置相关参数 + +- **nukeBuild/Build.AzureStorage.cs** + - 更新日志和验证逻辑以支持 channels + +### 不需要修改的文件 + +- **src/main/package-sources/http-index-source.ts**:已实现 channel 解析 +- **src/main/version-manager.ts**:Version 接口已包含 channel 字段 +- **src/renderer/** 前端相关文件:使用现有逻辑 + +## 实施计划 + +详见 `tasks.md` 文件。 + +## 成功标准 + +1. indexJSON 包含正确的 `channels` 对象 +2. 每个渠道包含 `latest` 版本和 `versions` 数组 +3. 版本到渠道的映射符合预期规则 +4. 生成的 indexJSON 与 azure-index.json 参考格式一致 +5. 向后兼容性:缺少 channels 时客户端仍能正常工作 + +## 风险与缓解 + +### 风险 + +- 版本命名不一致可能导致渠道分类错误 +- 多渠道同时发布时的版本管理复杂度 + +### 缓解措施 + +- 提供明确的版本到渠道映射规则 +- 支持自定义渠道映射配置 +- 充分测试各种版本命名模式 diff --git a/openspec/changes/archive/2026-02-15-indexjson-channel-support/tasks.md b/openspec/changes/archive/2026-02-15-indexjson-channel-support/tasks.md new file mode 100644 index 0000000..ce14b88 --- /dev/null +++ b/openspec/changes/archive/2026-02-15-indexjson-channel-support/tasks.md @@ -0,0 +1,208 @@ +# 实施任务清单 + +## Phase 1: 版本到渠道映射逻辑 + +### 1.1 实现版本解析和渠道提取 +**文件**: `nukeBuild/Adapters/AzureBlobAdapter.cs` + +**需求**: +- 新增 `ExtractChannelFromVersion(string version)` 方法 +- 从版本字符串(如 "0.1.0-beta.11")提取渠道标识 +- 支持常见渠道命名:beta、stable、canary、alpha、dev +- 返回默认渠道(如 "beta")当无法识别时 + +**实现示例**: +```csharp +private string ExtractChannelFromVersion(string version) +{ + if (string.IsNullOrWhiteSpace(version)) + return "beta"; + + // Check for common channel patterns + if (version.Contains("-stable.") || version.Contains("-stable")) + return "stable"; + if (version.Contains("-beta.") || version.Contains("-beta")) + return "beta"; + if (version.Contains("-canary.") || version.Contains("-canary")) + return "canary"; + if (version.Contains("-alpha.") || version.Contains("-alpha")) + return "alpha"; + if (version.Contains("-dev.") || version.Contains("-dev")) + return "dev"; + + // Default to beta for versions without explicit channel + return "beta"; +} +``` + +**验收标准**: +- [ ] "0.1.0-beta.11" → "beta" +- [ ] "1.0.0-stable" → "stable" +- [ ] "0.1.0-canary.5" → "canary" +- [ ] "2.0.0" → "beta" (默认) + +### 1.2 实现渠道分组方法 +**文件**: `nukeBuild/Adapters/AzureBlobAdapter.cs` + +**需求**: +- 新增 `GroupVersionsByChannel(List versions)` 方法 +- 输入版本列表,输出按渠道分组的字典 +- 每个渠道组包含版本标识符列表 + +**验收标准**: +- [ ] 正确分组不同渠道的版本 +- [ ] 处理空版本列表 +- [ ] 保持原始版本字符串格式 + +## Phase 2: channels 对象生成 + +### 2.1 扩展 GenerateIndexFromBlobsAsync +**文件**: `nukeBuild/Adapters/AzureBlobAdapter.cs` + +**需求**: +- 在现有 indexData 对象中添加 `channels` 字段 +- 调用版本到渠道映射方法 +- 为每个渠道构建包含 `latest` 和 `versions` 的对象 + +**实现位置**: +```csharp +// 在现有版本列表生成后添加 +var channelsData = BuildChannelsObject(versionList); + +var indexData = new +{ + updatedAt = DateTime.UtcNow.ToString("o"), + versions = versionList, + channels = channelsData // 新增 +}; +``` + +**验收标准**: +- [ ] indexJSON 包含 `channels` 对象 +- [ ] 每个渠道有 `latest` 和 `versions` 字段 +- [ ] `latest` 指向该渠道最新版本 + +### 2.2 实现渠道最新版本选择 +**文件**: `nukeBuild/Adapters/AzureBlobAdapter.cs` + +**需求**: +- 新增 `BuildChannelsObject(List versions)` 方法 +- 对每个渠道的版本进行语义化排序 +- 选择最高版本作为 `latest` + +**实现要点**: +- 使用 SemverExtensions.cs 中的扩展方法 +- 处理版本字符串中的预发布标识符(如 -beta.11) +- 正确比较不同预发布版本 + +**验收标准**: +- [ ] "0.1.0-beta.11" > "0.1.0-beta.10" +- [ ] "1.0.0" > "0.9.9" +- [ ] 每个渠道的 `latest` 正确 + +## Phase 3: Nuke 配置集成 + +### 3.1 利用现有 ReleaseChannel 参数 +**文件**: `nukeBuild/Build.cs` + +**需求**: +- 确认现有 `ReleaseChannel` 参数(已存在,第59行) +- 在 GenerateIndexFromBlobsAsync 中使用此参数 +- 支持通过命令行指定渠道 + +**验收标准**: +- [ ] `--release-channel beta` 正常工作 +- [ ] `--release-channel stable` 正常工作 +- [ ] 默认值为 "beta" + +### 3.2 添加可选的渠道映射配置 +**文件**: `nukeBuild/Build.cs` + +**需求**: +- 添加 `ChannelMapping` 参数(可选) +- 支持自定义版本到渠道的映射规则 +- JSON 格式配置 + +**参数示例**: +```csharp +[Parameter("Custom channel mapping (JSON)")] +readonly string ChannelMapping = ""; +``` + +**验收标准**: +- [ ] 支持自定义映射配置 +- [ ] 无配置时使用默认规则 +- [ ] JSON 解析错误时回退到默认行为 + +## Phase 4: 验证与测试 + +### 4.1 单元测试 +**文件**: 新建测试项目或使用现有测试 + +**需求**: +- 测试版本到渠道提取逻辑 +- 测试渠道分组功能 +- 测试最新版本选择 +- 测试边界情况(空版本、无效版本等) + +**验收标准**: +- [ ] 所有版本解析测试通过 +- [ ] 渠道分组测试通过 +- [ ] 最新版本选择测试通过 +- [ ] 边界情况测试覆盖 + +### 4.2 集成测试 + +**需求**: +- 运行完整的 `GenerateAzureIndex` target +- 验证生成的 indexJSON 格式 +- 与 azure-index.json 参考格式对比 + +**验收标准**: +- [ ] 生成的 indexJSON 包含 channels 字段 +- [ ] channels 对象结构与参考一致 +- [ ] versions 数组保持原有格式 +- [ ] 向后兼容性验证通过 + +### 4.3 手动验证清单 +- [ ] 运行 `./build.sh generate-azure-index` +- [ ] 检查生成的 `artifacts/azure-index.json` +- [ ] 验证 channels.beta.latest 指向正确版本 +- [ ] 验证 channels.beta.versions 包含所有 beta 版本 +- [ ] 使用不同渠道参数测试 + +## Phase 5: 文档与部署 + +### 5.1 更新构建文档 + +**需求**: +- 记录渠道参数使用方法 +- 提供版本命名约定 +- 更新 CI/CD 配置说明 + +**验收标准**: +- [ ] README.md 包含渠道配置说明 +- [ ] CI/CD 文档更新 +- [ ] 版本命名规范文档 + +## 优先级 + +1. **高优先级**: Phase 1 和 Phase 2(核心功能) +2. **中优先级**: Phase 3(配置优化) +3. **低优先级**: Phase 4 和 Phase 5(测试和文档) + +## 依赖关系 + +- Phase 2 依赖 Phase 1(需要先有映射逻辑) +- Phase 3 可与 Phase 2 并行(配置独立) +- Phase 4 依赖 Phase 2 完成(需要功能就绪) + +## 预期时间线 + +- Phase 1: 1-2 天 +- Phase 2: 2-3 天 +- Phase 3: 1-2 天 +- Phase 4: 2-3 天 +- Phase 5: 1 天 + +**总计**: 约 7-11 工作日