mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Rebuild release pipeline around PLONDS and DDSS
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record DdssBuildOptions(
|
||||
string ReleaseTag,
|
||||
string AssetsDirectory,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath,
|
||||
string Repository,
|
||||
string? S3BaseUrl = null);
|
||||
@@ -0,0 +1,68 @@
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class DdssManifestBuilder
|
||||
{
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
|
||||
public string Build(DdssBuildOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
|
||||
if (!Directory.Exists(assetsDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"DDSS assets directory not found: {assetsDirectory}");
|
||||
}
|
||||
|
||||
var assetEntries = Directory
|
||||
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
|
||||
.Where(static path =>
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
return !name.Equals("ddss.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !name.Equals("ddss.json.sig", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl))
|
||||
.ToArray();
|
||||
|
||||
var manifest = new DdssManifest(
|
||||
FormatVersion: "1.0",
|
||||
ReleaseTag: options.ReleaseTag,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
Assets: assetEntries);
|
||||
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
var manifestPath = Path.Combine(outputRoot, "ddss.json");
|
||||
PayloadUtilities.WriteJson(manifestPath, manifest);
|
||||
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
private static DdssAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
|
||||
{
|
||||
var fileName = Path.GetFileName(assetPath);
|
||||
var mirrors = new List<DdssMirrorEntry>
|
||||
{
|
||||
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
|
||||
{
|
||||
mirrors.Add(new DdssMirrorEntry(
|
||||
"s3",
|
||||
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
|
||||
}
|
||||
|
||||
return new DdssAssetEntry(
|
||||
AssetId: fileName,
|
||||
FileName: fileName,
|
||||
Sha256: PayloadUtilities.ComputeSha256(assetPath),
|
||||
Size: new FileInfo(assetPath).Length,
|
||||
Mirrors: mirrors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public static class PayloadUtilities
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static void CreatePayloadZip(string sourceDirectory, string outputZipPath)
|
||||
{
|
||||
var resolvedSourceDirectory = Path.GetFullPath(sourceDirectory);
|
||||
if (!Directory.Exists(resolvedSourceDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Payload source directory not found: {resolvedSourceDirectory}");
|
||||
}
|
||||
|
||||
var resolvedOutputZipPath = Path.GetFullPath(outputZipPath);
|
||||
var outputDirectory = Path.GetDirectoryName(resolvedOutputZipPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(resolvedOutputZipPath))
|
||||
{
|
||||
File.Delete(resolvedOutputZipPath);
|
||||
}
|
||||
|
||||
using var archive = ZipFile.Open(resolvedOutputZipPath, ZipArchiveMode.Create);
|
||||
foreach (var filePath in Directory.EnumerateFiles(resolvedSourceDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSourceDirectory, filePath));
|
||||
if (ShouldIgnore(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
archive.CreateEntryFromFile(filePath, relativePath, CompressionLevel.Optimal);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ExtractZip(string zipPath, string destinationDirectory)
|
||||
{
|
||||
var resolvedZipPath = Path.GetFullPath(zipPath);
|
||||
if (!File.Exists(resolvedZipPath))
|
||||
{
|
||||
throw new FileNotFoundException("Payload archive not found.", resolvedZipPath);
|
||||
}
|
||||
|
||||
EnsureCleanDirectory(destinationDirectory);
|
||||
ZipFile.ExtractToDirectory(resolvedZipPath, destinationDirectory, overwriteFiles: true);
|
||||
}
|
||||
|
||||
internal static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
|
||||
{
|
||||
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||
{
|
||||
return manifest;
|
||||
}
|
||||
|
||||
var resolvedRoot = Path.GetFullPath(root);
|
||||
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedRoot, filePath));
|
||||
if (ShouldIgnore(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
manifest[relativePath] = new FileFingerprint(
|
||||
relativePath,
|
||||
filePath,
|
||||
ComputeSha256(filePath),
|
||||
fileInfo.Length,
|
||||
ResolveUnixFileMode(filePath));
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
internal static string CopyObject(string sourcePath, string objectsRoot, string sha256)
|
||||
{
|
||||
var normalizedSha256 = sha256.Trim().ToLowerInvariant();
|
||||
var prefix = normalizedSha256[..Math.Min(2, normalizedSha256.Length)];
|
||||
var relativePath = NormalizeRelativePath(Path.Combine(prefix, normalizedSha256));
|
||||
var destinationPath = Path.Combine(objectsRoot, prefix, normalizedSha256);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
if (!File.Exists(destinationPath))
|
||||
{
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
internal static void EnsureCleanDirectory(string path)
|
||||
{
|
||||
var resolvedPath = Path.GetFullPath(path);
|
||||
if (Directory.Exists(resolvedPath))
|
||||
{
|
||||
Directory.Delete(resolvedPath, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(resolvedPath);
|
||||
}
|
||||
|
||||
internal static string ComputeSha256(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static void WriteJson<T>(string path, T value)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
File.WriteAllText(path, json, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
internal static string NormalizeRelativePath(string value)
|
||||
{
|
||||
return value.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
|
||||
internal static string ResolveArch(string platform)
|
||||
{
|
||||
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "x86";
|
||||
}
|
||||
|
||||
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arm64";
|
||||
}
|
||||
|
||||
return "x64";
|
||||
}
|
||||
|
||||
internal static bool ShouldIgnore(string relativePath)
|
||||
{
|
||||
var normalized = NormalizeRelativePath(relativePath.Trim());
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith("logs/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith("cache/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith("snapshots/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith("snapshot/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ResolveUnixFileMode(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
return Convert.ToString((int)mode, 8);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return InferUnixFileMode(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? InferUnixFileMode(string path)
|
||||
{
|
||||
if (!LooksExecutable(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return "755";
|
||||
}
|
||||
|
||||
private static bool LooksExecutable(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
var read = stream.Read(header);
|
||||
if (read >= 4 &&
|
||||
header[0] == 0x7F &&
|
||||
header[1] == (byte)'E' &&
|
||||
header[2] == (byte)'L' &&
|
||||
header[3] == (byte)'F')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (read >= 2 && header[0] == (byte)'#' && header[1] == (byte)'!')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
return string.IsNullOrWhiteSpace(extension) &&
|
||||
!OperatingSystem.IsWindows() &&
|
||||
Path.GetFileName(path).Contains("LanMountainDesktop", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size, string? UnixFileMode);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsDeltaBuildOptions(
|
||||
string Platform,
|
||||
string CurrentVersion,
|
||||
string CurrentTag,
|
||||
string CurrentPayloadZip,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath,
|
||||
string Channel = "stable",
|
||||
string? BaselineVersion = null,
|
||||
string? BaselineTag = null,
|
||||
string? BaselinePayloadZip = null,
|
||||
bool IsFullPayload = false);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsDeltaBuildResult(
|
||||
string Platform,
|
||||
string DistributionId,
|
||||
string UpdateArchivePath,
|
||||
string FileMapPath,
|
||||
string FileMapSignaturePath,
|
||||
string SummaryPath,
|
||||
bool IsFullPayload,
|
||||
string? BaselineTag,
|
||||
string? BaselineVersion,
|
||||
string TargetVersion);
|
||||
@@ -0,0 +1,228 @@
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsDeltaBuilder
|
||||
{
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
|
||||
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
||||
if (!File.Exists(currentPayloadZip))
|
||||
{
|
||||
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
|
||||
}
|
||||
|
||||
var baselinePayloadZip = string.IsNullOrWhiteSpace(options.BaselinePayloadZip)
|
||||
? null
|
||||
: Path.GetFullPath(options.BaselinePayloadZip);
|
||||
if (!string.IsNullOrWhiteSpace(baselinePayloadZip) && !File.Exists(baselinePayloadZip))
|
||||
{
|
||||
throw new FileNotFoundException("Baseline payload zip not found.", baselinePayloadZip);
|
||||
}
|
||||
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
||||
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
|
||||
var objectsRoot = Path.Combine(workRoot, "objects");
|
||||
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
|
||||
var summaryRoot = Path.Combine(outputRoot, "platform-summaries");
|
||||
|
||||
Directory.CreateDirectory(releaseAssetsRoot);
|
||||
Directory.CreateDirectory(summaryRoot);
|
||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||
|
||||
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
|
||||
if (useFullPayload)
|
||||
{
|
||||
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
|
||||
}
|
||||
else
|
||||
{
|
||||
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
|
||||
}
|
||||
|
||||
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
|
||||
|
||||
var previousManifest = useFullPayload
|
||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot);
|
||||
|
||||
var updateAssetName = $"update-{options.Platform}.zip";
|
||||
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
|
||||
var fileMapSignatureAssetName = fileMapAssetName + ".sig";
|
||||
var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}";
|
||||
var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName);
|
||||
var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName);
|
||||
var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName);
|
||||
|
||||
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["protocol"] = "PLONDS",
|
||||
["channel"] = options.Channel,
|
||||
["releaseTag"] = options.CurrentTag,
|
||||
["baselineTag"] = options.BaselineTag ?? string.Empty,
|
||||
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
|
||||
["targetVersion"] = options.CurrentVersion,
|
||||
["isFullPayload"] = useFullPayload ? "true" : "false"
|
||||
};
|
||||
|
||||
var component = new ComponentDocument(
|
||||
Name: "app",
|
||||
Version: options.CurrentVersion,
|
||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["component"] = "app",
|
||||
["mode"] = "file-object"
|
||||
},
|
||||
Files: fileEntries);
|
||||
|
||||
var fileMap = new FileMapDocument(
|
||||
FormatVersion: "1.0",
|
||||
DistributionId: distributionId,
|
||||
FromVersion: options.BaselineVersion ?? "0.0.0",
|
||||
ToVersion: options.CurrentVersion,
|
||||
Version: options.CurrentVersion,
|
||||
Platform: options.Platform,
|
||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
||||
Channel: options.Channel,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
Metadata: metadata,
|
||||
Components: [component],
|
||||
Files: fileEntries);
|
||||
|
||||
PayloadUtilities.WriteJson(fileMapPath, fileMap);
|
||||
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
|
||||
|
||||
var summary = new PlondsReleasePlatformEntry(
|
||||
Platform: options.Platform,
|
||||
DistributionId: distributionId,
|
||||
BaselineTag: options.BaselineTag,
|
||||
BaselineVersion: options.BaselineVersion ?? "0.0.0",
|
||||
TargetVersion: options.CurrentVersion,
|
||||
IsFullPayload: useFullPayload,
|
||||
FilesZipAsset: $"files-{options.Platform}.zip",
|
||||
UpdateZipAsset: updateAssetName,
|
||||
FileMapAsset: fileMapAssetName,
|
||||
FileMapSignatureAsset: fileMapSignatureAssetName,
|
||||
Sha256: PayloadUtilities.ComputeSha256(updateArchivePath));
|
||||
|
||||
var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json");
|
||||
PayloadUtilities.WriteJson(summaryPath, summary);
|
||||
|
||||
return new PlondsDeltaBuildResult(
|
||||
options.Platform,
|
||||
distributionId,
|
||||
updateArchivePath,
|
||||
fileMapPath,
|
||||
fileMapSignaturePath,
|
||||
summaryPath,
|
||||
useFullPayload,
|
||||
options.BaselineTag,
|
||||
options.BaselineVersion,
|
||||
options.CurrentVersion);
|
||||
}
|
||||
|
||||
private static List<FileEntryDocument> BuildFileEntries(
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||
string objectsRoot)
|
||||
{
|
||||
var result = new List<FileEntryDocument>();
|
||||
|
||||
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var current = currentManifest[path];
|
||||
if (previousManifest.TryGetValue(path, out var previous) &&
|
||||
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: "reuse",
|
||||
Sha256: current.Sha256,
|
||||
Size: current.Size,
|
||||
ObjectPath: null,
|
||||
ObjectKey: null,
|
||||
Metadata: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mode"] = "file-object"
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
|
||||
{
|
||||
metadata["unixFileMode"] = current.UnixFileMode!;
|
||||
}
|
||||
|
||||
result.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: action,
|
||||
Sha256: current.Sha256,
|
||||
Size: current.Size,
|
||||
ObjectPath: objectPath,
|
||||
ObjectKey: objectPath,
|
||||
Metadata: metadata));
|
||||
}
|
||||
|
||||
foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (currentManifest.ContainsKey(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: "delete",
|
||||
Sha256: string.Empty,
|
||||
Size: 0,
|
||||
ObjectPath: null,
|
||||
ObjectKey: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record FileMapDocument(
|
||||
string FormatVersion,
|
||||
string DistributionId,
|
||||
string FromVersion,
|
||||
string ToVersion,
|
||||
string Version,
|
||||
string Platform,
|
||||
string Arch,
|
||||
string Channel,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyList<FileEntryDocument> Files);
|
||||
|
||||
private sealed record ComponentDocument(
|
||||
string Name,
|
||||
string Version,
|
||||
IReadOnlyDictionary<string, string>? Metadata,
|
||||
IReadOnlyList<FileEntryDocument> Files);
|
||||
|
||||
private sealed record FileEntryDocument(
|
||||
string Path,
|
||||
string Action,
|
||||
string Sha256,
|
||||
long Size,
|
||||
string? ObjectPath,
|
||||
string? ObjectKey,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsReleaseIndexBuilder
|
||||
{
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
|
||||
public string Build(PlondsReleaseIndexOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var summariesDirectory = Path.GetFullPath(options.PlatformSummariesDirectory);
|
||||
if (!Directory.Exists(summariesDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Platform summary directory not found: {summariesDirectory}");
|
||||
}
|
||||
|
||||
var summaries = Directory
|
||||
.EnumerateFiles(summariesDirectory, "platform-summary-*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ReadSummary)
|
||||
.OrderBy(static entry => entry.Platform, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var manifest = new PlondsReleaseManifest(
|
||||
FormatVersion: "1.0",
|
||||
ReleaseTag: options.ReleaseTag,
|
||||
Version: options.Version,
|
||||
Channel: options.Channel,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
Platforms: summaries);
|
||||
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
|
||||
Directory.CreateDirectory(releaseAssetsRoot);
|
||||
|
||||
var manifestPath = Path.Combine(releaseAssetsRoot, "plonds.json");
|
||||
PayloadUtilities.WriteJson(manifestPath, manifest);
|
||||
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
private static PlondsReleasePlatformEntry ReadSummary(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var summary = JsonSerializer.Deserialize<PlondsReleasePlatformEntry>(json, PayloadUtilities.JsonOptions);
|
||||
if (summary is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}");
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsReleaseIndexOptions(
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string Channel,
|
||||
string PlatformSummariesDirectory,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssAssetEntry(
|
||||
string AssetId,
|
||||
string FileName,
|
||||
string Sha256,
|
||||
long Size,
|
||||
IReadOnlyList<DdssMirrorEntry> Mirrors);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssManifest(
|
||||
string FormatVersion,
|
||||
string ReleaseTag,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<DdssAssetEntry> Assets);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record DdssMirrorEntry(
|
||||
string Type,
|
||||
string Url);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsReleaseManifest(
|
||||
string FormatVersion,
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string Channel,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsReleasePlatformEntry(
|
||||
string Platform,
|
||||
string DistributionId,
|
||||
string? BaselineTag,
|
||||
string? BaselineVersion,
|
||||
string TargetVersion,
|
||||
bool IsFullPayload,
|
||||
string FilesZipAsset,
|
||||
string UpdateZipAsset,
|
||||
string FileMapAsset,
|
||||
string FileMapSignatureAsset,
|
||||
string Sha256);
|
||||
@@ -1,4 +1,4 @@
|
||||
using Plonds.Core.Publishing;
|
||||
using Plonds.Core.Publishing;
|
||||
using Plonds.Core.Security;
|
||||
|
||||
return await PlondsCli.RunAsync(args);
|
||||
@@ -29,6 +29,18 @@ internal static class PlondsCli
|
||||
case "publish":
|
||||
RunPublish(options);
|
||||
return Task.FromResult(0);
|
||||
case "pack-payload":
|
||||
RunPackPayload(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-delta":
|
||||
RunBuildDelta(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-index":
|
||||
RunBuildIndex(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-ddss":
|
||||
RunBuildDdss(options);
|
||||
return Task.FromResult(0);
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintUsage();
|
||||
@@ -101,6 +113,62 @@ internal static class PlondsCli
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunPackPayload(Dictionary<string, string> options)
|
||||
{
|
||||
var sourceDirectory = Require(options, "source-dir");
|
||||
var outputZip = Require(options, "output-zip");
|
||||
PayloadUtilities.CreatePayloadZip(sourceDirectory, outputZip);
|
||||
Console.WriteLine(outputZip);
|
||||
}
|
||||
|
||||
private static void RunBuildDelta(Dictionary<string, string> options)
|
||||
{
|
||||
var builder = new PlondsDeltaBuilder();
|
||||
var result = builder.Build(new PlondsDeltaBuildOptions(
|
||||
Platform: Require(options, "platform"),
|
||||
CurrentVersion: Require(options, "current-version"),
|
||||
CurrentTag: Require(options, "current-tag"),
|
||||
CurrentPayloadZip: Require(options, "current-zip"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
PrivateKeyPath: Require(options, "private-key"),
|
||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||
BaselineVersion: Get(options, "baseline-version"),
|
||||
BaselineTag: Get(options, "baseline-tag"),
|
||||
BaselinePayloadZip: Get(options, "baseline-zip"),
|
||||
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload));
|
||||
|
||||
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
|
||||
Console.WriteLine(result.FileMapPath);
|
||||
}
|
||||
|
||||
private static void RunBuildIndex(Dictionary<string, string> options)
|
||||
{
|
||||
var builder = new PlondsReleaseIndexBuilder();
|
||||
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
|
||||
ReleaseTag: Require(options, "release-tag"),
|
||||
Version: Require(options, "version"),
|
||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
PrivateKeyPath: Require(options, "private-key")));
|
||||
|
||||
Console.WriteLine(manifestPath);
|
||||
}
|
||||
|
||||
private static void RunBuildDdss(Dictionary<string, string> options)
|
||||
{
|
||||
var builder = new DdssManifestBuilder();
|
||||
var manifestPath = builder.Build(new DdssBuildOptions(
|
||||
ReleaseTag: Require(options, "release-tag"),
|
||||
AssetsDirectory: Require(options, "assets-dir"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
PrivateKeyPath: Require(options, "private-key"),
|
||||
Repository: Require(options, "repository"),
|
||||
S3BaseUrl: Get(options, "s3-base-url")));
|
||||
|
||||
Console.WriteLine(manifestPath);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -142,8 +210,12 @@ internal static class PlondsCli
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("PLONDS Tool");
|
||||
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
|
||||
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
|
||||
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
|
||||
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
|
||||
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
|
||||
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
|
||||
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
|
||||
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user