Skip to content
566 changes: 566 additions & 0 deletions .github/workflows/publish-dev.yml

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions .github/workflows/sync-azure-storage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
description: 'Release tag to sync'
required: true
type: string
release_version:
description: 'Version prefix to publish into Azure (defaults to release_tag)'
required: false
type: string
default: ''
release_channel:
description: 'Release channel (stable, beta, dev)'
required: false
Expand All @@ -26,6 +31,10 @@ on:
description: 'Release tag to sync (empty for latest release)'
required: false
type: string
release_version:
description: 'Version prefix to publish into Azure (defaults to release_tag)'
required: false
type: string
release_channel:
description: 'Release channel (stable, beta, dev)'
required: false
Expand Down Expand Up @@ -81,6 +90,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_BLOB_SAS_URL: ${{ secrets.AZURE_BLOB_SAS_URL }}
RELEASE_TAG: ${{ steps.release_info.outputs.tag }}
RELEASE_VERSION: ${{ inputs.release_version }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Expand Down
8 changes: 4 additions & 4 deletions nukeBuild.Tests/ArtifactHybridMetadataBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ public async Task BuildAsync_GeneratesHybridMetadataForEligibleArtifact()
await File.WriteAllBytesAsync(artifactPath, Enumerable.Repeat((byte)0x2A, 4096).ToArray());

var builder = new ArtifactHybridMetadataBuilder(new TorrentSidecarGenerator(pieceLengthBytes: 64), thresholdBytes: 1);
var result = await builder.BuildAsync(new[] { artifactPath }, "1.2.3", "https://example.blob.core.windows.net/releases/");
var result = await builder.BuildAsync(new[] { artifactPath }, "v1.2.3", "https://desktop.dl.hagicode.com/");

var artifact = Assert.Single(result.Artifacts);
Assert.True(artifact.MeetsThreshold);
Assert.True(artifact.HybridEligible);
Assert.False(artifact.LegacyHttpFallback);
Assert.Equal("1.2.3/hagicode-1.2.3-win-x64-nort.zip", artifact.Path);
Assert.Equal("https://example.blob.core.windows.net/releases/1.2.3/hagicode-1.2.3-win-x64-nort.zip", artifact.DirectUrl);
Assert.Equal("https://example.blob.core.windows.net/releases/1.2.3/hagicode-1.2.3-win-x64-nort.zip.torrent", artifact.TorrentUrl);
Assert.Equal("v1.2.3/hagicode-1.2.3-win-x64-nort.zip", artifact.Path);
Assert.Equal("https://desktop.dl.hagicode.com/v1.2.3/hagicode-1.2.3-win-x64-nort.zip", artifact.DirectUrl);
Assert.Equal("https://desktop.dl.hagicode.com/v1.2.3/hagicode-1.2.3-win-x64-nort.zip.torrent", artifact.TorrentUrl);
Assert.Contains(artifact.DirectUrl, artifact.WebSeeds);
Assert.False(string.IsNullOrWhiteSpace(artifact.InfoHash));
Assert.False(string.IsNullOrWhiteSpace(artifact.Sha256));
Expand Down
32 changes: 19 additions & 13 deletions nukeBuild.Tests/AzureBlobAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,21 @@ public void BuildIndexResult_WritesAssetsAndFilesAndPreservesFallbacks()
{
AbsolutePath root = Path.GetTempPath();
var adapter = new AzureBlobAdapter(root);
var directUrl = "https://example.blob.core.windows.net/releases/1.0.0/hagicode-1.0.0-win-x64-nort.zip";
var directUrl = "https://desktop.dl.hagicode.com/v1.0.0/hagicode-1.0.0-win-x64-nort.zip";
var publishedArtifacts = new[]
{
new PublishedArtifactMetadata
{
Name = "hagicode-1.0.0-win-x64-nort.zip",
LocalFilePath = "/tmp/hagicode-1.0.0-win-x64-nort.zip",
Path = "1.0.0/hagicode-1.0.0-win-x64-nort.zip",
Path = "v1.0.0/hagicode-1.0.0-win-x64-nort.zip",
Size = 2048,
LastModified = DateTime.UnixEpoch,
DirectUrl = directUrl,
MeetsThreshold = true,
HybridEligible = true,
LegacyHttpFallback = false,
TorrentPath = "1.0.0/hagicode-1.0.0-win-x64-nort.zip.torrent",
TorrentPath = "v1.0.0/hagicode-1.0.0-win-x64-nort.zip.torrent",
TorrentUrl = $"{directUrl}.torrent",
InfoHash = "abc123",
Sha256 = "deadbeef",
Expand All @@ -37,14 +37,20 @@ public void BuildIndexResult_WritesAssetsAndFilesAndPreservesFallbacks()
};
var blobs = new List<AzureBlobInfo>
{
new() { Name = "1.0.0/hagicode-1.0.0-win-x64-nort.zip", Size = 2048, LastModified = DateTime.UnixEpoch },
new() { Name = "1.0.0/hagicode-1.0.0-win-x64-nort.zip.torrent", Size = 512, LastModified = DateTime.UnixEpoch },
new() { Name = "0.9.0/legacy.zip", Size = 128, LastModified = DateTime.UnixEpoch },
new() { Name = "0.8.0/missing.zip", Size = 4096, LastModified = DateTime.UnixEpoch },
new() { Name = "0.8.0/missing.zip.torrent", Size = 256, LastModified = DateTime.UnixEpoch },
new() { Name = "v1.0.0/hagicode-1.0.0-win-x64-nort.zip", Size = 2048, LastModified = DateTime.UnixEpoch },
new() { Name = "v1.0.0/hagicode-1.0.0-win-x64-nort.zip.torrent", Size = 512, LastModified = DateTime.UnixEpoch },
new() { Name = "v1.0.0/hagicode-desktop-1.0.0.zip", Size = 64, LastModified = DateTime.UnixEpoch },
new() { Name = "v1.0.0/hagicode-desktop-1.0.0.tar.gz", Size = 64, LastModified = DateTime.UnixEpoch },
new() { Name = "v0.9.0/legacy.zip", Size = 128, LastModified = DateTime.UnixEpoch },
new() { Name = "v0.8.0/missing.zip", Size = 4096, LastModified = DateTime.UnixEpoch },
new() { Name = "v0.8.0/missing.zip.torrent", Size = 256, LastModified = DateTime.UnixEpoch },
};

var result = adapter.BuildIndexResult(blobs, "https://example.blob.core.windows.net/releases?sig=test", publishedArtifacts);
var result = adapter.BuildIndexResult(
blobs,
"https://example.blob.core.windows.net/releases?sig=test",
publishedArtifacts,
"https://desktop.dl.hagicode.com");
var json = JsonSerializer.Serialize(result.Document, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Expand All @@ -58,16 +64,16 @@ public void BuildIndexResult_WritesAssetsAndFilesAndPreservesFallbacks()
Assert.Equal(2, result.HttpOnlyFallbackCount);
Assert.Contains(result.Diagnostics, (diagnostic) => diagnostic.Code == "historical-http-only");

var latestVersion = versions.EnumerateArray().First((entry) => entry.GetProperty("version").GetString() == "1.0.0");
var latestVersion = versions.EnumerateArray().First((entry) => entry.GetProperty("version").GetString() == "v1.0.0");
var latestAsset = latestVersion.GetProperty("assets")[0];
Assert.Equal("hagicode-1.0.0-win-x64-nort.zip", latestAsset.GetProperty("name").GetString());
Assert.Equal($"{directUrl}.torrent", latestAsset.GetProperty("torrentUrl").GetString());
Assert.Equal("deadbeef", latestAsset.GetProperty("sha256").GetString());
Assert.Equal("1.0.0/hagicode-1.0.0-win-x64-nort.zip", latestVersion.GetProperty("files")[0].GetString());
Assert.Equal("v1.0.0/hagicode-1.0.0-win-x64-nort.zip", latestVersion.GetProperty("files")[0].GetString());

var legacyVersion = versions.EnumerateArray().First((entry) => entry.GetProperty("version").GetString() == "0.9.0");
var legacyVersion = versions.EnumerateArray().First((entry) => entry.GetProperty("version").GetString() == "v0.9.0");
var legacyAsset = legacyVersion.GetProperty("assets")[0];
Assert.False(legacyAsset.TryGetProperty("torrentUrl", out _));
Assert.Equal("https://example.blob.core.windows.net/releases/0.9.0/legacy.zip", legacyAsset.GetProperty("directUrl").GetString());
Assert.Equal("https://desktop.dl.hagicode.com/v0.9.0/legacy.zip", legacyAsset.GetProperty("directUrl").GetString());
}
}
11 changes: 8 additions & 3 deletions nukeBuild/Adapters/AzureBlobAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@
}

var blobs = await ListBlobsAsync(options);
var result = BuildIndexResult(blobs, options.SasUrl, publishedArtifacts);
var result = BuildIndexResult(blobs, options.SasUrl, publishedArtifacts, options.PublicBaseUrl);
result.IndexJson = SerializeJson(result.Document!, minify);
await File.WriteAllTextAsync(outputPath, result.IndexJson);

Expand All @@ -348,9 +348,10 @@
public AzureBlobIndexGenerationResult BuildIndexResult(
IReadOnlyCollection<AzureBlobInfo> blobs,
string sasUrl,
IReadOnlyCollection<PublishedArtifactMetadata>? publishedArtifacts = null)
IReadOnlyCollection<PublishedArtifactMetadata> publishedArtifacts = null,
string publicBaseUrl = "")
{
var containerBaseUrl = AzureBlobPathUtilities.BuildContainerBaseUrl(sasUrl);
var containerBaseUrl = AzureBlobPathUtilities.ResolvePublicBaseUrl(sasUrl, publicBaseUrl);
var metadataByPath = (publishedArtifacts ?? Array.Empty<PublishedArtifactMetadata>())
.ToDictionary((artifact) => artifact.Path, StringComparer.OrdinalIgnoreCase);

Expand All @@ -369,6 +370,10 @@
var blobsByName = versionGroup.ToDictionary((blob) => blob.Name, StringComparer.OrdinalIgnoreCase);
var artifactBlobs = versionGroup
.Where((blob) => !blob.Name.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
.Where((blob) => !AzureBlobPathUtilities.IsGitHubGeneratedSourceArchive(
Path.GetFileName(blob.Name),
BuildConfig.GitHubReleaseRepositoryName,
versionGroup.Key))
.OrderBy((blob) => blob.Name, StringComparer.OrdinalIgnoreCase)
.ToList();

Expand Down Expand Up @@ -602,10 +607,10 @@
public long Size { get; init; }
public required string LastModified { get; init; }
public required string DirectUrl { get; init; }
public string? TorrentUrl { get; set; }

Check warning on line 610 in nukeBuild/Adapters/AzureBlobAdapter.cs

View workflow job for this annotation

GitHub Actions / Sync Dev Release to Azure Storage / Sync Release Assets to Azure Storage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
public List<string>? WebSeeds { get; set; }

Check warning on line 611 in nukeBuild/Adapters/AzureBlobAdapter.cs

View workflow job for this annotation

GitHub Actions / Sync Dev Release to Azure Storage / Sync Release Assets to Azure Storage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
public string? InfoHash { get; set; }

Check warning on line 612 in nukeBuild/Adapters/AzureBlobAdapter.cs

View workflow job for this annotation

GitHub Actions / Sync Dev Release to Azure Storage / Sync Release Assets to Azure Storage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
public string? Sha256 { get; set; }

Check warning on line 613 in nukeBuild/Adapters/AzureBlobAdapter.cs

View workflow job for this annotation

GitHub Actions / Sync Dev Release to Azure Storage / Sync Release Assets to Azure Storage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
}

public class ChannelInfo
Expand Down
2 changes: 1 addition & 1 deletion nukeBuild/Adapters/AzureReleasePublishOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public async Task<ReleasePublishSummary> PublishAsync(
bool minifyIndexJson)
{
var summary = new ReleasePublishSummary();
var containerBaseUrl = Utils.AzureBlobPathUtilities.BuildContainerBaseUrl(options.SasUrl);
var containerBaseUrl = Utils.AzureBlobPathUtilities.ResolvePublicBaseUrl(options.SasUrl, options.PublicBaseUrl);
var metadataResult = await _metadataBuilder.BuildAsync(downloadedFiles, options.VersionPrefix, containerBaseUrl);

summary.EligibleAssetCount = metadataResult.EligibleArtifactCount;
Expand Down
6 changes: 6 additions & 0 deletions nukeBuild/AzureStorageConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public class AzureBlobPublishOptions
/// </summary>
public string VersionPrefix { get; set; } = string.Empty;

/// <summary>
/// Public base URL used in generated download links and web seeds.
/// Falls back to the SAS container URL when empty.
/// </summary>
public string PublicBaseUrl { get; set; } = string.Empty;

/// <summary>
/// Container name (parsed from SAS URL)
/// </summary>
Expand Down
40 changes: 37 additions & 3 deletions nukeBuild/Build.AzureStorage.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Adapters;
using AzureStorage;
using System.Diagnostics;
using Utils;

public partial class Build
{
Expand Down Expand Up @@ -80,7 +81,13 @@
Log.Information("使用指定的 ReleaseTag: {Tag}", versionTag);
}

BuildConfig.Version = versionTag;
var effectiveVersion = NormalizePublishedVersionPrefix(
!string.IsNullOrWhiteSpace(ReleaseVersion)
? ReleaseVersion
: versionTag);

BuildConfig.Version = effectiveVersion;
Log.Information("Azure publish version prefix: {Version}", effectiveVersion);

if (!UploadArtifacts && !UploadIndex)
{
Expand Down Expand Up @@ -115,10 +122,23 @@
Log.Information("使用 gh CLI 下载 release 资产...");

await DownloadReleaseAssetsUsingGhAsync(versionTag, downloadDirectory);
downloadedFiles = Directory.GetFiles(downloadDirectory)
var allDownloadedFiles = Directory.GetFiles(downloadDirectory)
.Where((path) => !File.GetAttributes(path).HasFlag(FileAttributes.Directory))
.ToList();

downloadedFiles = allDownloadedFiles
.Where((path) => !AzureBlobPathUtilities.IsGitHubGeneratedSourceArchive(
Path.GetFileName(path),
BuildConfig.GitHubReleaseRepositoryName,
effectiveVersion))
.ToList();

var filteredAssetCount = allDownloadedFiles.Count - downloadedFiles.Count;
if (filteredAssetCount > 0)
{
Log.Information("已过滤 {Count} 个 GitHub 自动生成源码包资产", filteredAssetCount);
}

if (downloadedFiles.Count > 0)
{
Log.Information("成功下载 {Count} 个资源", downloadedFiles.Count);
Expand All @@ -140,7 +160,8 @@
{
SasUrl = AzureBlobSasUrl,
UploadRetries = AzureUploadRetries,
VersionPrefix = versionTag,
VersionPrefix = effectiveVersion,
PublicBaseUrl = AzurePublicBaseUrl,
};
var localIndexPath = (RootDirectory / "artifacts" / "azure-index.json").ToString();

Expand Down Expand Up @@ -213,6 +234,7 @@

Log.Information("=== 同步完成 ===");
Log.Information(" Release Tag: {Tag}", versionTag);
Log.Information(" Azure Version Prefix: {Version}", effectiveVersion);
Log.Information(" 下载资源: {DownloadCount}", downloadedFiles.Count);
Log.Information(" 产物上传: {ArtifactsStatus}", UploadArtifacts ? "已执行" : "已跳过");
Log.Information(" Index 上传: {IndexStatus}", UploadIndex ? "已执行" : "已跳过");
Expand Down Expand Up @@ -286,6 +308,18 @@
};
}

private static string NormalizePublishedVersionPrefix(string versionOrTag)
{
var normalized = versionOrTag.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}

normalized = normalized.TrimStart('v', 'V');
return $"v{normalized}";
}

private async Task DownloadReleaseAssetsUsingGhAsync(string tag, AbsolutePath downloadDirectory)
{
Directory.CreateDirectory(downloadDirectory);
Expand Down Expand Up @@ -335,7 +369,7 @@
/// <summary>
/// 使用 gh CLI 获取最新 release tag
/// </summary>
private async Task<string?> GetLatestReleaseTagUsingGhAsync()

Check warning on line 372 in nukeBuild/Build.AzureStorage.cs

View workflow job for this annotation

GitHub Actions / Sync Dev Release to Azure Storage / Sync Release Assets to Azure Storage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
try
{
Expand Down
6 changes: 6 additions & 0 deletions nukeBuild/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ partial class Build : NukeBuild
[Parameter("Azure Blob SAS URL for authentication and upload")]
[Secret] readonly string AzureBlobSasUrl = "";

[Parameter("Public base URL for published desktop artifacts")]
readonly string AzurePublicBaseUrl = BuildConfig.DesktopPublicBaseUrl;

[Parameter("Skip Azure Blob publish")] readonly bool SkipAzureBlobPublish = false;

[Parameter("Generate Azure index.json")] readonly bool AzureGenerateIndex = true;
Expand Down Expand Up @@ -55,6 +58,9 @@ partial class Build : NukeBuild
[Parameter("Release tag to sync (e.g., v1.0.0)")]
readonly string ReleaseTag = "";

[Parameter("Version prefix to publish (defaults to release tag)")]
readonly string ReleaseVersion = "";

[Parameter("Release channel (stable, beta, dev)")]
readonly string ReleaseChannel = "beta";

Expand Down
3 changes: 3 additions & 0 deletions nukeBuild/BuildConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
/// </summary>
internal static class BuildConfig
{
internal const string GitHubReleaseRepositoryName = "hagicode-desktop";
internal const string DesktopPublicBaseUrl = "https://desktop.dl.hagicode.com";

/// <summary>
/// The release packaged directory path
/// </summary>
Expand Down
33 changes: 31 additions & 2 deletions nukeBuild/Utils/AzureBlobPathUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Utils;

public static class AzureBlobPathUtilities
{
public static string NormalizeVersionPrefix(string? versionPrefix)
public static string NormalizeVersionPrefix(string versionPrefix)
{
if (string.IsNullOrWhiteSpace(versionPrefix))
{
Expand All @@ -12,7 +12,7 @@ public static string NormalizeVersionPrefix(string? versionPrefix)
return versionPrefix.Trim().Trim('/').Replace('\\', '/');
}

public static string BuildBlobPath(string? versionPrefix, string fileName)
public static string BuildBlobPath(string versionPrefix, string fileName)
{
var normalizedPrefix = NormalizeVersionPrefix(versionPrefix);
var normalizedFileName = fileName.Replace('\\', '/');
Expand All @@ -27,6 +27,16 @@ public static string BuildContainerBaseUrl(string sasUrl)
return $"{uri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/";
}

public static string ResolvePublicBaseUrl(string sasUrl, string publicBaseUrl = "")
{
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return $"{publicBaseUrl.Trim().TrimEnd('/')}/";
}

return BuildContainerBaseUrl(sasUrl);
}

public static string BuildBlobUrl(string containerBaseUrl, string blobPath)
{
var baseUri = new Uri(containerBaseUrl.EndsWith('/') ? containerBaseUrl : $"{containerBaseUrl}/");
Expand All @@ -38,4 +48,23 @@ public static string ExtractVersion(string blobName)
var slashIndex = blobName.IndexOf('/');
return slashIndex > 0 ? blobName[..slashIndex] : "latest";
}

public static bool IsGitHubGeneratedSourceArchive(string fileName, string repositoryName, string releaseVersionOrTag)
{
if (string.IsNullOrWhiteSpace(fileName) ||
string.IsNullOrWhiteSpace(repositoryName) ||
string.IsNullOrWhiteSpace(releaseVersionOrTag))
{
return false;
}

var normalizedVersion = releaseVersionOrTag.Trim().TrimStart('v', 'V');
if (string.IsNullOrWhiteSpace(normalizedVersion))
{
return false;
}

return fileName.Equals($"{repositoryName}-{normalizedVersion}.zip", StringComparison.OrdinalIgnoreCase)
|| fileName.Equals($"{repositoryName}-{normalizedVersion}.tar.gz", StringComparison.OrdinalIgnoreCase);
}
}
Loading