mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Launcher (#4)
* 激进的更新 * 试试 * fix.可爱的我一直在修CI( * fix.启动器一定要能够启动 * feat.尝试弄了AOT的启动器。 * fix.修CI,好像是因为Linux那边有个问题,反正修就对了。 * fix.ci难修,为什么liunx跑不起来呢? * Update build.yml * Update LanMountainDesktop.csproj * changed.调整了启动逻辑,优化了更新页面。 * changed.优化了更新体验 * feat.依旧试增量更新这一块,看看velopack * fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 * fix.继续修ci,ci怎么天天炸 * changed.velopack,试试rust * fix.修ci,修融合桌面,修启动器 * fix.GitHub Action工作流怎么天天出问题 * feat.引入velopack,不好,是rust(至少内存很安全了。 * chore: migrate release pipeline to signed filemap and wire rainyun s3 * fix: make optional s3 upload step workflow-parse safe * fix: make delta pack generation robust for empty diffs and linux paths * chore: rotate launcher update public key for pdc signing * fix: restore stable launcher update public key * fix: sync launcher public key with update signing secret * fix: normalize PEM line endings in signing key validation * fix: rotate launcher public key to match ci signing secret * fix: compare signing keys by SPKI instead of PEM text * refactor update backend to host-managed PDC pipeline * fix release workflow env key collisions * relax publish-pdc precheck to require S3 only * set GH_TOKEN for PDCC installer step * ci: add local pdc mock fallback for release publish * ci: fix pdc mock process log redirection * ci: fallback pdcc signing key to update private key * ci: ensure pdcc signing passphrase env is always set * ci: create pdcc publish root before invoking client * ci: set pdcc version variable from release version * ci: decouple pdcc installer version from publish config version * ci: package pdcc subchannels with generated filemap and changelog * ci: make local pdc mock diff return empty for fast fallback * ci: fix pdcc variable mapping and pdc signing prechecks * Update App.axaml.cs * ci: wire aws cli credentials for rainyun s3 * ci: pin pdcc client version separately from app version * ci: harden local pdc mock transport handling * ci: publish pdcc subchannels in one pass * ci: add pdcc publish heartbeat and timeout * ci: fix pdcc publish workdir bootstrap * feat.Penguin Logistics Online Network Distribution System * ci: fix plonds s3 probe and signing fallback * ci: validate signing key and quiet missing baselines * ci: relax aws checksum mode for rainyun s3 * ci: avoid multipart uploads to rainyun s3 * ci: handle empty plonds baselines safely * ci.plonds * Rebuild release pipeline around PLONDS and DDSS * Fix Windows installer script path in release workflow
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,13 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlatformPublishResult(
|
||||
string Platform,
|
||||
string DistributionId,
|
||||
string CurrentAppDirectory,
|
||||
string? PreviousDirectory,
|
||||
string PreviousVersion,
|
||||
string FileMapPath,
|
||||
string SignaturePath,
|
||||
string DistributionPath,
|
||||
string LatestPath,
|
||||
IReadOnlyList<string> InstallerFiles);
|
||||
@@ -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,23 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsGenerateOptions(
|
||||
string CurrentVersion,
|
||||
string CurrentDirectory,
|
||||
string Platform,
|
||||
string OutputRoot,
|
||||
string PreviousVersion = "0.0.0",
|
||||
string? PreviousDirectory = null,
|
||||
string Channel = "stable",
|
||||
string? DistributionId = null,
|
||||
string? RepoBaseUrl = null,
|
||||
string? FileMapUrl = null,
|
||||
string? FileMapSignatureUrl = null,
|
||||
string? InstallerDirectory = null,
|
||||
string? InstallerBaseUrl = null,
|
||||
string IncrementalStrategy = "release-payload",
|
||||
string? BaselineVersion = null,
|
||||
string? BaselineRef = null,
|
||||
string? SourceCommit = null,
|
||||
bool IsFullPayloadRelease = false,
|
||||
string? CommitRangeStart = null,
|
||||
string? CommitRangeEnd = null);
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsGenerator
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public PlatformPublishResult Generate(PlondsGenerateOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var currentDirectory = Path.GetFullPath(options.CurrentDirectory);
|
||||
if (!Directory.Exists(currentDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Current directory not found: {currentDirectory}");
|
||||
}
|
||||
|
||||
var previousDirectory = string.IsNullOrWhiteSpace(options.PreviousDirectory)
|
||||
? null
|
||||
: Path.GetFullPath(options.PreviousDirectory);
|
||||
|
||||
var distributionId = string.IsNullOrWhiteSpace(options.DistributionId)
|
||||
? $"plonds-{options.CurrentVersion}-{options.Platform}"
|
||||
: options.DistributionId.Trim();
|
||||
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
var repoRoot = Path.Combine(outputRoot, "repo", "sha256");
|
||||
var manifestsRoot = Path.Combine(outputRoot, "manifests", distributionId);
|
||||
var metaDistributionRoot = Path.Combine(outputRoot, "meta", "distributions");
|
||||
var metaChannelRoot = Path.Combine(outputRoot, "meta", "channels", options.Channel, options.Platform);
|
||||
var installerMirrorRoot = Path.Combine(outputRoot, "installers", options.Platform, options.CurrentVersion);
|
||||
|
||||
Directory.CreateDirectory(repoRoot);
|
||||
Directory.CreateDirectory(manifestsRoot);
|
||||
Directory.CreateDirectory(metaDistributionRoot);
|
||||
Directory.CreateDirectory(metaChannelRoot);
|
||||
|
||||
var previousManifest = options.IsFullPayloadRelease
|
||||
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: ScanDirectory(previousDirectory);
|
||||
var currentManifest = ScanDirectory(currentDirectory);
|
||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
||||
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
||||
var publishedAt = DateTimeOffset.UtcNow;
|
||||
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
||||
? options.PreviousVersion
|
||||
: options.BaselineVersion;
|
||||
|
||||
var fileMap = new FileMapDocument(
|
||||
FormatVersion: "1.0",
|
||||
DistributionId: distributionId,
|
||||
FromVersion: options.PreviousVersion,
|
||||
ToVersion: options.CurrentVersion,
|
||||
Platform: options.Platform,
|
||||
Channel: options.Channel,
|
||||
PublishedAt: publishedAt,
|
||||
Capabilities: ["file-object"],
|
||||
Components:
|
||||
[
|
||||
new ComponentDocument(
|
||||
Id: "app",
|
||||
Root: "/",
|
||||
Mode: "file-object",
|
||||
Files: fileEntries,
|
||||
Metadata: new Dictionary<string, string> { ["component"] = "app" })
|
||||
],
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["protocol"] = "PLONDS",
|
||||
["mode"] = "file-object",
|
||||
["baselineVersion"] = baselineVersion,
|
||||
["incrementalStrategy"] = options.IncrementalStrategy,
|
||||
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
||||
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
||||
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
||||
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
||||
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
||||
});
|
||||
|
||||
var distribution = new DistributionDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
PublishedAt: publishedAt,
|
||||
FileMapUrl: options.FileMapUrl,
|
||||
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
||||
Components: fileMap.Components,
|
||||
InstallerMirrors: installerMirrors,
|
||||
Capabilities: ["file-object"],
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["protocol"] = "PLONDS",
|
||||
["baselineVersion"] = baselineVersion,
|
||||
["incrementalStrategy"] = options.IncrementalStrategy,
|
||||
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
|
||||
["sourceCommit"] = options.SourceCommit ?? string.Empty,
|
||||
["baselineRef"] = options.BaselineRef ?? string.Empty,
|
||||
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
|
||||
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
|
||||
});
|
||||
|
||||
var latest = new LatestPointerDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
PublishedAt: publishedAt);
|
||||
|
||||
var fileMapPath = Path.Combine(manifestsRoot, "plonds-filemap.json");
|
||||
var distributionPath = Path.Combine(metaDistributionRoot, distributionId + ".json");
|
||||
var latestPath = Path.Combine(metaChannelRoot, "latest.json");
|
||||
|
||||
WriteJson(fileMapPath, fileMap);
|
||||
WriteJson(distributionPath, distribution);
|
||||
WriteJson(latestPath, latest);
|
||||
|
||||
return new PlatformPublishResult(
|
||||
options.Platform,
|
||||
distributionId,
|
||||
currentDirectory,
|
||||
previousDirectory,
|
||||
options.PreviousVersion,
|
||||
fileMapPath,
|
||||
fileMapPath + ".sig",
|
||||
distributionPath,
|
||||
latestPath,
|
||||
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
|
||||
}
|
||||
|
||||
private 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 = Path.GetRelativePath(resolvedRoot, filePath).Replace('\\', '/');
|
||||
if (ShouldIgnore(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
manifest[relativePath] = new FileFingerprint(relativePath, filePath, ComputeSha256(filePath), fileInfo.Length);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static List<FileEntryDocument> BuildFileEntries(
|
||||
Dictionary<string, FileFingerprint> previousManifest,
|
||||
Dictionary<string, FileFingerprint> currentManifest,
|
||||
string repoRoot,
|
||||
string? repoBaseUrl)
|
||||
{
|
||||
var entries = new List<FileEntryDocument>();
|
||||
|
||||
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var current = currentManifest[path];
|
||||
if (previousManifest.TryGetValue(path, out var previous) &&
|
||||
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entries.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: "reuse",
|
||||
Sha256: current.Sha256,
|
||||
Size: current.Size,
|
||||
Mode: "file-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
|
||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||
? null
|
||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
||||
|
||||
entries.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: action,
|
||||
Sha256: current.Sha256,
|
||||
Size: current.Size,
|
||||
Mode: "file-object",
|
||||
ObjectKey: objectKey,
|
||||
ObjectUrl: objectUrl,
|
||||
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
|
||||
}
|
||||
|
||||
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!currentManifest.ContainsKey(path))
|
||||
{
|
||||
entries.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: "delete",
|
||||
Sha256: string.Empty,
|
||||
Size: 0,
|
||||
Mode: "file-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<InstallerMirrorDocument> BuildInstallerMirrors(
|
||||
string platform,
|
||||
string installerMirrorRoot,
|
||||
string? installerSourceDirectory,
|
||||
string? installerBaseUrl)
|
||||
{
|
||||
var result = new List<InstallerMirrorDocument>();
|
||||
if (string.IsNullOrWhiteSpace(installerSourceDirectory) || !Directory.Exists(installerSourceDirectory))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(installerMirrorRoot);
|
||||
foreach (var sourceFile in Directory.EnumerateFiles(installerSourceDirectory))
|
||||
{
|
||||
var fileName = Path.GetFileName(sourceFile);
|
||||
var destinationPath = Path.Combine(installerMirrorRoot, fileName);
|
||||
File.Copy(sourceFile, destinationPath, overwrite: true);
|
||||
|
||||
var url = string.IsNullOrWhiteSpace(installerBaseUrl)
|
||||
? null
|
||||
: $"{installerBaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}";
|
||||
result.Add(new InstallerMirrorDocument(
|
||||
Platform: platform,
|
||||
Arch: ResolveArch(platform),
|
||||
Url: url,
|
||||
Name: fileName,
|
||||
FileName: fileName,
|
||||
Sha256: ComputeSha256(destinationPath),
|
||||
Size: new FileInfo(destinationPath).Length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ResolveArch(string platform)
|
||||
{
|
||||
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "x86";
|
||||
}
|
||||
|
||||
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "arm64";
|
||||
}
|
||||
|
||||
return "x64";
|
||||
}
|
||||
|
||||
private static bool ShouldIgnore(string relativePath)
|
||||
{
|
||||
var normalized = relativePath.Trim().Replace('\\', '/');
|
||||
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);
|
||||
}
|
||||
|
||||
private static string CopyContentObject(string sourcePath, string repoRoot, string sha256)
|
||||
{
|
||||
var prefix = sha256[..Math.Min(2, sha256.Length)];
|
||||
var relativeKey = $"{prefix}/{sha256}";
|
||||
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
if (!File.Exists(destinationPath))
|
||||
{
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
return relativeKey.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteJson<T>(string path, T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
File.WriteAllText(path, json, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size);
|
||||
|
||||
private sealed record FileMapDocument(
|
||||
string FormatVersion,
|
||||
string DistributionId,
|
||||
string FromVersion,
|
||||
string ToVersion,
|
||||
string Platform,
|
||||
string Channel,
|
||||
DateTimeOffset PublishedAt,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record DistributionDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? FileMapUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record LatestPointerDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset PublishedAt);
|
||||
|
||||
private sealed record ComponentDocument(
|
||||
string Id,
|
||||
string Root,
|
||||
string Mode,
|
||||
IReadOnlyList<FileEntryDocument> Files,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record FileEntryDocument(
|
||||
string Path,
|
||||
string Action,
|
||||
string Sha256,
|
||||
long Size,
|
||||
string Mode,
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record InstallerMirrorDocument(
|
||||
string Platform,
|
||||
string Arch,
|
||||
string? Url,
|
||||
string? Name,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsPublishOptions(
|
||||
string Version,
|
||||
string AppArtifactsRoot,
|
||||
string InstallerArtifactsRoot,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath,
|
||||
string Channel = "stable",
|
||||
string? BaselineRoot = null,
|
||||
string? RepoBaseUrl = null,
|
||||
string? InstallerBaseUrl = null,
|
||||
string IncrementalStrategy = "release-payload",
|
||||
string? BaselineVersion = null,
|
||||
string? BaselineRef = null,
|
||||
string? SourceCommit = null,
|
||||
bool IsFullPayloadRelease = false,
|
||||
string? CommitRangeStart = null,
|
||||
string? CommitRangeEnd = null);
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Shared;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsPublisher
|
||||
{
|
||||
private static readonly PlatformConfig[] SupportedPlatforms =
|
||||
[
|
||||
new("windows-x64", "app-payload-windows-x64", [".exe"], ["x64"]),
|
||||
new("windows-x86", "app-payload-windows-x86", [".exe"], ["x86"]),
|
||||
new("linux-x64", "app-payload-linux-x64", [".deb"], ["linux", "x64"])
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly PlondsGenerator _generator = new();
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
|
||||
public IReadOnlyList<PlatformPublishResult> Publish(PlondsPublishOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var results = new List<PlatformPublishResult>();
|
||||
var releaseAssetsRoot = Path.Combine(Path.GetFullPath(options.OutputRoot), "release-assets");
|
||||
Directory.CreateDirectory(releaseAssetsRoot);
|
||||
|
||||
foreach (var config in SupportedPlatforms)
|
||||
{
|
||||
var artifactRoot = Path.Combine(Path.GetFullPath(options.AppArtifactsRoot), config.ArtifactName);
|
||||
if (!Directory.Exists(artifactRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"App payload artifact root not found for {config.Platform}: {artifactRoot}");
|
||||
}
|
||||
|
||||
var currentAppDirectory = FindCurrentAppDirectory(artifactRoot, options.Version);
|
||||
if (currentAppDirectory is null)
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Unable to locate app payload directory for {config.Platform} under {artifactRoot}");
|
||||
}
|
||||
|
||||
var baselineRoot = string.IsNullOrWhiteSpace(options.BaselineRoot)
|
||||
? Path.Combine(Path.GetFullPath(options.OutputRoot), "_baselines")
|
||||
: Path.GetFullPath(options.BaselineRoot);
|
||||
var platformBaselineRoot = Path.Combine(baselineRoot, config.Platform);
|
||||
var previousDirectory = Path.Combine(platformBaselineRoot, "current");
|
||||
var previousVersionPath = Path.Combine(platformBaselineRoot, "version.txt");
|
||||
Directory.CreateDirectory(platformBaselineRoot);
|
||||
if (!Directory.Exists(previousDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(previousDirectory);
|
||||
}
|
||||
|
||||
var previousVersion = File.Exists(previousVersionPath)
|
||||
? File.ReadAllText(previousVersionPath).Trim()
|
||||
: "0.0.0";
|
||||
|
||||
var installerSourceDirectory = PrepareInstallerMirrorInput(
|
||||
config,
|
||||
options.InstallerArtifactsRoot,
|
||||
Path.Combine(platformBaselineRoot, "installers"));
|
||||
|
||||
var distributionId = $"plonds-{options.Version}-{config.Platform}";
|
||||
var repoBaseUrl = options.RepoBaseUrl;
|
||||
var fileMapUrl = repoBaseUrl is null
|
||||
? null
|
||||
: $"{repoBaseUrl.TrimEnd('/').Replace("/repo/sha256", "/manifests")}/{distributionId}/plonds-filemap.json";
|
||||
var fileMapSignatureUrl = fileMapUrl is null ? null : fileMapUrl + ".sig";
|
||||
var installerBaseUrl = string.IsNullOrWhiteSpace(options.InstallerBaseUrl)
|
||||
? null
|
||||
: $"{options.InstallerBaseUrl.TrimEnd('/')}/{config.Platform}/{options.Version}";
|
||||
|
||||
var result = _generator.Generate(new PlondsGenerateOptions(
|
||||
CurrentVersion: options.Version,
|
||||
CurrentDirectory: currentAppDirectory,
|
||||
Platform: config.Platform,
|
||||
OutputRoot: options.OutputRoot,
|
||||
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
||||
PreviousDirectory: previousDirectory,
|
||||
Channel: options.Channel,
|
||||
DistributionId: distributionId,
|
||||
RepoBaseUrl: repoBaseUrl,
|
||||
FileMapUrl: fileMapUrl,
|
||||
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||
InstallerDirectory: installerSourceDirectory,
|
||||
InstallerBaseUrl: installerBaseUrl,
|
||||
IncrementalStrategy: options.IncrementalStrategy,
|
||||
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
||||
BaselineRef: options.BaselineRef,
|
||||
SourceCommit: options.SourceCommit,
|
||||
IsFullPayloadRelease: options.IsFullPayloadRelease,
|
||||
CommitRangeStart: options.CommitRangeStart,
|
||||
CommitRangeEnd: options.CommitRangeEnd));
|
||||
|
||||
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
|
||||
|
||||
CopyReleaseAsset(result.FileMapPath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json"));
|
||||
CopyReleaseAsset(result.SignaturePath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json.sig"));
|
||||
CopyReleaseAsset(result.DistributionPath, Path.Combine(releaseAssetsRoot, $"plonds-distribution-{config.Platform}.json"));
|
||||
CopyReleaseAsset(result.LatestPath, Path.Combine(releaseAssetsRoot, $"plonds-latest-{config.Platform}.json"));
|
||||
|
||||
MirrorBaseline(currentAppDirectory, previousDirectory, previousVersionPath, options.Version);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
WriteMetadataCatalog(options, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void WriteMetadataCatalog(PlondsPublishOptions options, IReadOnlyList<PlatformPublishResult> results)
|
||||
{
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
var metadataRoot = Path.Combine(outputRoot, "meta");
|
||||
Directory.CreateDirectory(metadataRoot);
|
||||
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
var latestPointers = results
|
||||
.Select(result => new PlondsChannelPointer(
|
||||
Channel: options.Channel,
|
||||
Platform: result.Platform,
|
||||
DistributionId: result.DistributionId,
|
||||
Version: options.Version,
|
||||
PublishedAt: generatedAt,
|
||||
DistributionPath: $"distributions/{result.DistributionId}.json",
|
||||
FileMapPath: $"../manifests/{result.DistributionId}/plonds-filemap.json"))
|
||||
.OrderBy(pointer => pointer.Channel, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(pointer => pointer.Platform, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var catalog = new PlondsMetadataCatalog(
|
||||
ProtocolName: PlondsConstants.ProtocolName,
|
||||
ProtocolVersion: PlondsConstants.ProtocolVersion,
|
||||
StorageRoot: outputRoot,
|
||||
MetaRoot: metadataRoot,
|
||||
Latest: latestPointers,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["generatedBy"] = "Plonds.Tool",
|
||||
["channel"] = options.Channel,
|
||||
["generatedAt"] = generatedAt.ToString("O")
|
||||
});
|
||||
|
||||
var metadataPath = Path.Combine(metadataRoot, "metadata.json");
|
||||
File.WriteAllText(metadataPath, JsonSerializer.Serialize(catalog, JsonOptions), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void MirrorBaseline(string currentAppDirectory, string previousDirectory, string previousVersionPath, string version)
|
||||
{
|
||||
if (Directory.Exists(previousDirectory))
|
||||
{
|
||||
Directory.Delete(previousDirectory, recursive: true);
|
||||
}
|
||||
|
||||
CopyDirectory(currentAppDirectory, previousDirectory);
|
||||
File.WriteAllText(previousVersionPath, version);
|
||||
}
|
||||
|
||||
private static string? FindCurrentAppDirectory(string artifactRoot, string version)
|
||||
{
|
||||
var preferred = Directory.EnumerateDirectories(artifactRoot, $"app-{version}", SearchOption.AllDirectories).FirstOrDefault();
|
||||
if (preferred is not null)
|
||||
{
|
||||
return preferred;
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(artifactRoot, "app-*", SearchOption.AllDirectories)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string PrepareInstallerMirrorInput(PlatformConfig config, string installerArtifactsRoot, string destinationRoot)
|
||||
{
|
||||
var installerFiles = FindInstallerFiles(config, installerArtifactsRoot);
|
||||
if (Directory.Exists(destinationRoot))
|
||||
{
|
||||
Directory.Delete(destinationRoot, recursive: true);
|
||||
}
|
||||
Directory.CreateDirectory(destinationRoot);
|
||||
|
||||
foreach (var file in installerFiles)
|
||||
{
|
||||
File.Copy(file, Path.Combine(destinationRoot, Path.GetFileName(file)), overwrite: true);
|
||||
}
|
||||
|
||||
return destinationRoot;
|
||||
}
|
||||
|
||||
private static List<string> FindInstallerFiles(PlatformConfig config, string installerArtifactsRoot)
|
||||
{
|
||||
var files = Directory.EnumerateFiles(Path.GetFullPath(installerArtifactsRoot), "*", SearchOption.AllDirectories);
|
||||
return files
|
||||
.Where(file => config.InstallerExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
|
||||
.Where(file =>
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
return config.FileNameTokens.All(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase));
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void CopyReleaseAsset(string sourcePath, string destinationPath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, file);
|
||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
File.Copy(file, destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PlatformConfig(
|
||||
string Platform,
|
||||
string ArtifactName,
|
||||
IReadOnlyList<string> InstallerExtensions,
|
||||
IReadOnlyList<string> FileNameTokens);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user