mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat..去除了冗余的字体文件,又修改了PLONDS系统
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsBuildOptions(
|
||||
string ReleaseTag,
|
||||
string AssetsDirectory,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath,
|
||||
string Repository,
|
||||
string? S3BaseUrl = null);
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public static class PlondsCommitAnalyzer
|
||||
{
|
||||
private static readonly string[] SourceDirectories =
|
||||
[
|
||||
"LanMountainDesktop/",
|
||||
"LanMountainDesktop.Launcher/",
|
||||
"LanMountainDesktop.Shared.Contracts/",
|
||||
"LanMountainDesktop.PluginSdk/",
|
||||
"LanMountainDesktop.Appearance/",
|
||||
"LanMountainDesktop.Settings.Core/",
|
||||
"LanMountainDesktop.ComponentSystem/"
|
||||
];
|
||||
|
||||
private static readonly (string Prefix, string[] Artifacts)[] SourceToArtifactMappings =
|
||||
[
|
||||
("LanMountainDesktop/", ["LanMountainDesktop.dll", "LanMountainDesktop.exe"]),
|
||||
("LanMountainDesktop.Launcher/", ["LanMountainDesktop.Launcher.exe", "LanMountainDesktop.Launcher.dll"]),
|
||||
("LanMountainDesktop.Shared.Contracts/", ["LanMountainDesktop.Shared.Contracts.dll"]),
|
||||
("LanMountainDesktop.PluginSdk/", ["LanMountainDesktop.PluginSdk.dll"]),
|
||||
("LanMountainDesktop.Appearance/", ["LanMountainDesktop.Appearance.dll"]),
|
||||
("LanMountainDesktop.Settings.Core/", ["LanMountainDesktop.Settings.Core.dll"]),
|
||||
("LanMountainDesktop.ComponentSystem/", ["LanMountainDesktop.ComponentSystem.dll"])
|
||||
];
|
||||
|
||||
private static readonly string[] SourceCodeExtensions =
|
||||
[
|
||||
".cs", ".axaml", ".xaml", ".csproj"
|
||||
];
|
||||
|
||||
public static HashSet<string> GetChangedSourceFiles(string baselineTag, string currentTag)
|
||||
{
|
||||
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var start = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
Arguments = $"log --name-only --pretty=format: {baselineTag}..{currentTag}",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
|
||||
if (start is null)
|
||||
{
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
var output = start.StandardOutput.ReadToEnd();
|
||||
start.WaitForExit();
|
||||
|
||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim().Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSourceDirectoryFile(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
changedFiles.Add(trimmed);
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
public static HashSet<string> MapSourceFilesToArtifacts(IReadOnlySet<string> sourceFiles)
|
||||
{
|
||||
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var hasUnmappedChanges = false;
|
||||
|
||||
foreach (var sourceFile in sourceFiles)
|
||||
{
|
||||
var normalized = sourceFile.Replace('\\', '/');
|
||||
var mapped = false;
|
||||
|
||||
foreach (var (prefix, artifactList) in SourceToArtifactMappings)
|
||||
{
|
||||
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSourceCodeFile(normalized) && !IsConfigFile(normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var artifact in artifactList)
|
||||
{
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
|
||||
mapped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!mapped && IsConfigFile(normalized))
|
||||
{
|
||||
var artifactPath = MapConfigToArtifact(normalized);
|
||||
if (artifactPath is not null)
|
||||
{
|
||||
artifacts.Add(artifactPath);
|
||||
mapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mapped)
|
||||
{
|
||||
hasUnmappedChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUnmappedChanges)
|
||||
{
|
||||
foreach (var (_, artifactList) in SourceToArtifactMappings)
|
||||
{
|
||||
foreach (var artifact in artifactList)
|
||||
{
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
public static bool IsSourceDirectoryFile(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', '/');
|
||||
return SourceDirectories.Any(d => normalized.StartsWith(d, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsSourceCodeFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path);
|
||||
return SourceCodeExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsConfigFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path);
|
||||
return string.Equals(ext, ".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? MapConfigToArtifact(string sourcePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(sourcePath);
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsCommitDeltaBuildOptions(
|
||||
string Platform,
|
||||
string CurrentVersion,
|
||||
string CurrentPayloadZip,
|
||||
string OutputRoot,
|
||||
string Channel,
|
||||
string BaselineTag,
|
||||
string CurrentTag,
|
||||
string? FallbackBaselineZip = null,
|
||||
string? BaselineVersion = null,
|
||||
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
|
||||
string HashAlgorithm = "sha256");
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsCommitDeltaBuildResult(
|
||||
string Platform,
|
||||
string ChangedZipPath,
|
||||
string ManifestPath,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
bool FellBackToFileCompare,
|
||||
string CurrentVersion,
|
||||
string? BaselineVersion,
|
||||
IReadOnlyList<string> ChangedSourceFiles,
|
||||
IReadOnlyList<string> MappedArtifactFiles);
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Plonds.Shared;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsCommitDeltaBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public PlondsCommitDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var hashAlgorithm = PlondsDeltaBuilder.ValidateHashAlgorithmInternal(options.HashAlgorithm);
|
||||
|
||||
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
||||
if (!File.Exists(currentPayloadZip))
|
||||
{
|
||||
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
|
||||
}
|
||||
|
||||
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
||||
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
||||
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||
|
||||
var changedSourceFiles = PlondsCommitAnalyzer.GetChangedSourceFiles(options.BaselineTag, options.CurrentTag);
|
||||
|
||||
if (changedSourceFiles.Count == 0)
|
||||
{
|
||||
return FallbackToFileCompare(options, currentPayloadZip, outputRoot, workRoot, hashAlgorithm);
|
||||
}
|
||||
|
||||
var mappedArtifacts = PlondsCommitAnalyzer.MapSourceFilesToArtifacts(changedSourceFiles);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
|
||||
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var artifact in mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!currentManifest.TryGetValue(artifact, out var fingerprint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hash = GetHash(fingerprint, hashAlgorithm);
|
||||
var action = PlondsConstants.ActionReplace;
|
||||
|
||||
filesMap[artifact] = new PlondsFileEntry(action, hash, fingerprint.Size, hashAlgorithm);
|
||||
changedFilesMap[artifact] = new PlondsChangedFileEntry(artifact, hash, fingerprint.Size, hashAlgorithm);
|
||||
}
|
||||
|
||||
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, mappedArtifacts, outputRoot, options.Platform);
|
||||
|
||||
var requiresCleanInstall = mappedArtifacts.Contains(options.LauncherRelativePath, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||
|
||||
var manifest = new PlondsManifest(
|
||||
FormatVersion: PlondsConstants.FormatVersion,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
PreviousVersion: options.BaselineVersion ?? options.BaselineTag.TrimStart('v'),
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
|
||||
HashAlgorithm: hashAlgorithm,
|
||||
FilesMap: filesMap,
|
||||
ChangedFilesMap: changedFilesMap,
|
||||
Checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||
});
|
||||
|
||||
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
File.WriteAllText(manifestPath, manifestJson);
|
||||
|
||||
return new PlondsCommitDeltaBuildResult(
|
||||
Platform: options.Platform,
|
||||
ChangedZipPath: changedZipPath,
|
||||
ManifestPath: manifestPath,
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
FellBackToFileCompare: false,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
BaselineVersion: options.BaselineVersion,
|
||||
ChangedSourceFiles: changedSourceFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
MappedArtifactFiles: mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
}
|
||||
|
||||
private PlondsCommitDeltaBuildResult FallbackToFileCompare(
|
||||
PlondsCommitDeltaBuildOptions options,
|
||||
string currentPayloadZip,
|
||||
string outputRoot,
|
||||
string workRoot,
|
||||
string hashAlgorithm)
|
||||
{
|
||||
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
|
||||
? null
|
||||
: Path.GetFullPath(options.FallbackBaselineZip);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
|
||||
{
|
||||
var currentExtractRoot = Path.Combine(workRoot, "current");
|
||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
|
||||
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var fp = currentManifest[path];
|
||||
var hash = GetHash(fp, hashAlgorithm);
|
||||
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionAdd, hash, fp.Size, hashAlgorithm);
|
||||
changedFilesMap[path] = new PlondsChangedFileEntry(path, hash, fp.Size, hashAlgorithm);
|
||||
}
|
||||
|
||||
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, filesMap.Keys.ToHashSet(), outputRoot, options.Platform);
|
||||
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||
|
||||
var manifest = new PlondsManifest(
|
||||
FormatVersion: PlondsConstants.FormatVersion,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
PreviousVersion: "0.0.0",
|
||||
IsFullUpdate: true,
|
||||
RequiresCleanInstall: false,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
|
||||
HashAlgorithm: hashAlgorithm,
|
||||
FilesMap: filesMap,
|
||||
ChangedFilesMap: changedFilesMap,
|
||||
Checksums: new Dictionary<string, string>
|
||||
{
|
||||
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||
});
|
||||
|
||||
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
File.WriteAllText(manifestPath, manifestJson);
|
||||
|
||||
return new PlondsCommitDeltaBuildResult(
|
||||
Platform: options.Platform,
|
||||
ChangedZipPath: changedZipPath,
|
||||
ManifestPath: manifestPath,
|
||||
IsFullUpdate: true,
|
||||
RequiresCleanInstall: false,
|
||||
FellBackToFileCompare: true,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
BaselineVersion: options.BaselineVersion,
|
||||
ChangedSourceFiles: [],
|
||||
MappedArtifactFiles: []);
|
||||
}
|
||||
|
||||
var deltaBuilder = new PlondsDeltaBuilder();
|
||||
var deltaResult = deltaBuilder.Build(new PlondsDeltaBuildOptions(
|
||||
Platform: options.Platform,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
CurrentPayloadZip: currentPayloadZip,
|
||||
OutputRoot: outputRoot,
|
||||
Channel: options.Channel,
|
||||
BaselineVersion: options.BaselineVersion,
|
||||
BaselinePayloadZip: fallbackZip,
|
||||
LauncherRelativePath: options.LauncherRelativePath,
|
||||
HashAlgorithm: hashAlgorithm));
|
||||
|
||||
return new PlondsCommitDeltaBuildResult(
|
||||
Platform: deltaResult.Platform,
|
||||
ChangedZipPath: deltaResult.ChangedZipPath,
|
||||
ManifestPath: deltaResult.ManifestPath,
|
||||
IsFullUpdate: deltaResult.IsFullUpdate,
|
||||
RequiresCleanInstall: deltaResult.RequiresCleanInstall,
|
||||
FellBackToFileCompare: true,
|
||||
CurrentVersion: deltaResult.CurrentVersion,
|
||||
BaselineVersion: deltaResult.BaselineVersion,
|
||||
ChangedSourceFiles: [],
|
||||
MappedArtifactFiles: []);
|
||||
}
|
||||
|
||||
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
|
||||
{
|
||||
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
|
||||
{
|
||||
return ComputeMd5Hex(fingerprint.FullPath);
|
||||
}
|
||||
|
||||
return fingerprint.Sha256;
|
||||
}
|
||||
|
||||
private static string CreateChangedZipFromArtifacts(
|
||||
string currentExtractRoot,
|
||||
IReadOnlySet<string> artifacts,
|
||||
string outputRoot,
|
||||
string platform)
|
||||
{
|
||||
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
|
||||
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
|
||||
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
var sourcePath = Path.Combine(currentExtractRoot, artifact);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var destPath = Path.Combine(stagingRoot, artifact);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrWhiteSpace(destDir))
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, destPath, overwrite: true);
|
||||
}
|
||||
|
||||
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
|
||||
return changedZipPath;
|
||||
}
|
||||
|
||||
private static string ComputeMd5Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
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,
|
||||
string? StaticOutputRoot = null,
|
||||
string? UpdateBaseUrl = null);
|
||||
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
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);
|
||||
string ChangedZipPath,
|
||||
string ManifestPath,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string CurrentVersion,
|
||||
string? BaselineVersion);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
using Plonds.Core.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Plonds.Shared;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsDeltaBuilder
|
||||
{
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var hashAlgorithm = ValidateHashAlgorithmInternal(options.HashAlgorithm);
|
||||
|
||||
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
||||
if (!File.Exists(currentPayloadZip))
|
||||
{
|
||||
@@ -29,332 +37,200 @@ public sealed class PlondsDeltaBuilder
|
||||
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);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
||||
|
||||
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
|
||||
if (useFullPayload)
|
||||
{
|
||||
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
|
||||
}
|
||||
else
|
||||
var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip);
|
||||
if (!isFullUpdate)
|
||||
{
|
||||
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
|
||||
}
|
||||
|
||||
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
|
||||
|
||||
var previousManifest = useFullPayload
|
||||
var previousManifest = isFullUpdate
|
||||
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
|
||||
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
|
||||
var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl)
|
||||
? null
|
||||
: options.UpdateBaseUrl.TrimEnd('/');
|
||||
var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl)
|
||||
? null
|
||||
: $"{updateBaseUrl}/repo/sha256";
|
||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl);
|
||||
|
||||
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);
|
||||
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
|
||||
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
|
||||
|
||||
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
|
||||
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
|
||||
|
||||
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 launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
|
||||
var requiresCleanInstall = launcherChanged && !isFullUpdate;
|
||||
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
var component = new ComponentDocument(
|
||||
Name: "app",
|
||||
Version: options.CurrentVersion,
|
||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["component"] = "app",
|
||||
["mode"] = "file-object"
|
||||
},
|
||||
Files: fileEntries);
|
||||
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
||||
|
||||
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: generatedAt,
|
||||
Metadata: metadata,
|
||||
Components: [component],
|
||||
Files: fileEntries);
|
||||
|
||||
PayloadUtilities.WriteJson(fileMapPath, fileMap);
|
||||
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl))
|
||||
{
|
||||
WriteStaticLayout(
|
||||
options,
|
||||
component,
|
||||
objectsRoot,
|
||||
distributionId,
|
||||
fileMapPath,
|
||||
fileMapSignaturePath,
|
||||
Path.GetFullPath(options.StaticOutputRoot),
|
||||
updateBaseUrl,
|
||||
generatedAt);
|
||||
}
|
||||
|
||||
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,
|
||||
string? repoBaseUrl)
|
||||
{
|
||||
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,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
|
||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||
? null
|
||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectPath}";
|
||||
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,
|
||||
ObjectUrl: objectUrl,
|
||||
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,
|
||||
ObjectUrl: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void WriteStaticLayout(
|
||||
PlondsDeltaBuildOptions options,
|
||||
ComponentDocument component,
|
||||
string objectsRoot,
|
||||
string distributionId,
|
||||
string fileMapPath,
|
||||
string fileMapSignaturePath,
|
||||
string staticOutputRoot,
|
||||
string updateBaseUrl,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256");
|
||||
var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId);
|
||||
var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions");
|
||||
var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform);
|
||||
|
||||
CopyDirectory(objectsRoot, repoRoot);
|
||||
Directory.CreateDirectory(manifestRoot);
|
||||
File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true);
|
||||
File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true);
|
||||
|
||||
var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json";
|
||||
var distribution = new DistributionDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
SourceVersion: options.BaselineVersion ?? "0.0.0",
|
||||
var manifest = new PlondsManifest(
|
||||
FormatVersion: PlondsConstants.FormatVersion,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
PreviousVersion: options.BaselineVersion ?? "0.0.0",
|
||||
IsFullUpdate: isFullUpdate,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
Channel: options.Channel,
|
||||
Platform: options.Platform,
|
||||
Arch: PayloadUtilities.ResolveArch(options.Platform),
|
||||
PublishedAt: generatedAt,
|
||||
FileMapUrl: fileMapUrl,
|
||||
FileMapSignatureUrl: fileMapUrl + ".sig",
|
||||
Components: [component],
|
||||
InstallerMirrors: [],
|
||||
Capabilities: ["file-object"],
|
||||
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
CompareMethod: PlondsConstants.CompareMethodFileCompare,
|
||||
HashAlgorithm: hashAlgorithm,
|
||||
FilesMap: filesMap,
|
||||
ChangedFilesMap: changedFilesMap,
|
||||
Checksums: new Dictionary<string, string>
|
||||
{
|
||||
["protocol"] = "PLONDS",
|
||||
["releaseTag"] = options.CurrentTag,
|
||||
["baselineTag"] = options.BaselineTag ?? string.Empty,
|
||||
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
|
||||
["targetVersion"] = options.CurrentVersion,
|
||||
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
|
||||
["changed.zip"] = $"md5:{changedZipMd5}"
|
||||
});
|
||||
|
||||
var latest = new LatestPointerDocument(
|
||||
DistributionId: distributionId,
|
||||
Version: options.CurrentVersion,
|
||||
Channel: options.Channel,
|
||||
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
File.WriteAllText(manifestPath, manifestJson);
|
||||
|
||||
return new PlondsDeltaBuildResult(
|
||||
Platform: options.Platform,
|
||||
PublishedAt: generatedAt);
|
||||
|
||||
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution);
|
||||
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest);
|
||||
ChangedZipPath: changedZipPath,
|
||||
ManifestPath: manifestPath,
|
||||
IsFullUpdate: isFullUpdate,
|
||||
RequiresCleanInstall: requiresCleanInstall,
|
||||
CurrentVersion: options.CurrentVersion,
|
||||
BaselineVersion: options.BaselineVersion);
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
internal static string ValidateHashAlgorithmInternal(string algorithm)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||
var normalized = algorithm.Trim().ToLowerInvariant();
|
||||
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
||||
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 static Dictionary<string, PlondsFileEntry> BuildFilesMap(
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||
string hashAlgorithm)
|
||||
{
|
||||
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private sealed record ComponentDocument(
|
||||
string Name,
|
||||
string Version,
|
||||
IReadOnlyDictionary<string, string>? Metadata,
|
||||
IReadOnlyList<FileEntryDocument> Files);
|
||||
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var current = currentManifest[path];
|
||||
var currentHash = GetHash(current, hashAlgorithm);
|
||||
|
||||
private sealed record FileEntryDocument(
|
||||
string Path,
|
||||
string Action,
|
||||
string Sha256,
|
||||
long Size,
|
||||
string? ObjectPath,
|
||||
string? ObjectKey,
|
||||
string? ObjectUrl,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
if (previousManifest.TryGetValue(path, out var previous))
|
||||
{
|
||||
var previousHash = GetHash(previous, hashAlgorithm);
|
||||
if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DistributionDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string SourceVersion,
|
||||
string Channel,
|
||||
string Platform,
|
||||
string Arch,
|
||||
DateTimeOffset PublishedAt,
|
||||
string FileMapUrl,
|
||||
string FileMapSignatureUrl,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
var action = previousManifest.ContainsKey(path)
|
||||
? PlondsConstants.ActionReplace
|
||||
: PlondsConstants.ActionAdd;
|
||||
filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm);
|
||||
}
|
||||
|
||||
private sealed record LatestPointerDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset PublishedAt);
|
||||
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!currentManifest.ContainsKey(path))
|
||||
{
|
||||
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record InstallerMirrorDocument(
|
||||
string Platform,
|
||||
string? Url,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
return filesMap;
|
||||
}
|
||||
|
||||
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
|
||||
{
|
||||
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
|
||||
{
|
||||
return ComputeMd5Hex(fingerprint.FullPath);
|
||||
}
|
||||
|
||||
return fingerprint.Sha256;
|
||||
}
|
||||
|
||||
private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
|
||||
string hashAlgorithm)
|
||||
{
|
||||
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (path, entry) in filesMap)
|
||||
{
|
||||
if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
|
||||
{
|
||||
changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFilesMap;
|
||||
}
|
||||
|
||||
private static string CreateChangedZip(
|
||||
string currentExtractRoot,
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
|
||||
string outputRoot,
|
||||
string platform)
|
||||
{
|
||||
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
|
||||
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
|
||||
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
|
||||
|
||||
foreach (var (path, entry) in filesMap)
|
||||
{
|
||||
if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourcePath = Path.Combine(currentExtractRoot, path);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var destPath = Path.Combine(stagingRoot, path);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrWhiteSpace(destDir))
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, destPath, overwrite: true);
|
||||
}
|
||||
|
||||
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
|
||||
return changedZipPath;
|
||||
}
|
||||
|
||||
private static bool DetectLauncherChange(
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
|
||||
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
|
||||
string launcherRelativePath)
|
||||
{
|
||||
var normalizedPath = launcherRelativePath.Replace('\\', '/');
|
||||
|
||||
if (!currentManifest.TryGetValue(normalizedPath, out var current))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!previousManifest.TryGetValue(normalizedPath, out var previous))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ComputeMd5Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
@@ -1,451 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
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 generatedAt = DateTimeOffset.UtcNow;
|
||||
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
||||
? options.PreviousVersion
|
||||
: options.BaselineVersion;
|
||||
var arch = ResolveArch(options.Platform);
|
||||
|
||||
var fileMap = new FileMapDocument(
|
||||
FormatVersion: "2.0",
|
||||
DistributionId: distributionId,
|
||||
FromVersion: options.PreviousVersion,
|
||||
ToVersion: options.CurrentVersion,
|
||||
Version: options.CurrentVersion,
|
||||
Platform: options.Platform,
|
||||
Arch: arch,
|
||||
Channel: options.Channel,
|
||||
PublishedAt: publishedAt,
|
||||
GeneratedAt: generatedAt,
|
||||
BaselineVersion: baselineVersion,
|
||||
Capabilities: ["file-object", "compressed-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,
|
||||
Arch: arch,
|
||||
PublishedAt: publishedAt,
|
||||
FileMapUrl: options.FileMapUrl,
|
||||
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
||||
Components: fileMap.Components,
|
||||
InstallerMirrors: installerMirrors,
|
||||
Capabilities: ["file-object", "compressed-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());
|
||||
}
|
||||
|
||||
public static void WriteBundle(string fileMapPath, string signatureBase64)
|
||||
{
|
||||
var fileMapJson = File.ReadAllText(fileMapPath);
|
||||
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
|
||||
}
|
||||
|
||||
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,
|
||||
ArchiveSha256: null,
|
||||
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
|
||||
current.FullPath, repoRoot, current.Sha256, current.Size);
|
||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||
? null
|
||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
||||
|
||||
entries.Add(new FileEntryDocument(
|
||||
Path: path,
|
||||
Action: action,
|
||||
Sha256: current.Sha256,
|
||||
Size: current.Size,
|
||||
Mode: mode,
|
||||
ObjectKey: objectKey,
|
||||
ObjectUrl: objectUrl,
|
||||
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
|
||||
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
|
||||
}
|
||||
|
||||
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,
|
||||
ArchiveSha256: 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 ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
|
||||
string sourcePath, string repoRoot, string sha256, long fileSize)
|
||||
{
|
||||
if (fileSize > 65536)
|
||||
{
|
||||
var compressedBytes = CompressGzip(sourcePath);
|
||||
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
|
||||
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
|
||||
return (archiveKey, archiveSha256, "compressed-object");
|
||||
}
|
||||
|
||||
var key = CopyContentObject(sourcePath, repoRoot, sha256);
|
||||
return (key, string.Empty, "file-object");
|
||||
}
|
||||
|
||||
private static byte[] CompressGzip(string filePath)
|
||||
{
|
||||
using var input = File.OpenRead(filePath);
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
|
||||
{
|
||||
input.CopyTo(gzip);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static string ComputeSha256FromBytes(byte[] data)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string CopyBytesToObjectStore(byte[] data, 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.WriteAllBytes(destinationPath, data);
|
||||
}
|
||||
return relativeKey.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
|
||||
{
|
||||
var bundle = new BundleDocument(fileMapJson, signatureBase64);
|
||||
WriteJson(fileMapPath + ".bundle.json", bundle);
|
||||
}
|
||||
|
||||
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 Version,
|
||||
string Platform,
|
||||
string Arch,
|
||||
string Channel,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string? BaselineVersion,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyList<ComponentDocument> Components,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record DistributionDocument(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
string Arch,
|
||||
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,
|
||||
string? ArchiveSha256,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record InstallerMirrorDocument(
|
||||
string Platform,
|
||||
string Arch,
|
||||
string? Url,
|
||||
string? Name,
|
||||
string? FileName,
|
||||
string? Sha256,
|
||||
long Size);
|
||||
|
||||
private sealed record BundleDocument(string Manifest, string Signature);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Shared.Models;
|
||||
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed class PlondsManifestBuilder
|
||||
{
|
||||
private readonly RsaFileSigner _signer = new();
|
||||
|
||||
public string Build(PlondsBuildOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
|
||||
if (!Directory.Exists(assetsDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"PLONDS assets directory not found: {assetsDirectory}");
|
||||
}
|
||||
|
||||
var assetEntries = Directory
|
||||
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
|
||||
.Where(static path =>
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
return !name.Equals("plonds.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !name.Equals("plonds.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 PlondsManifest(
|
||||
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, "plonds.json");
|
||||
PayloadUtilities.WriteJson(manifestPath, manifest);
|
||||
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
private static PlondsAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
|
||||
{
|
||||
var fileName = Path.GetFileName(assetPath);
|
||||
var mirrors = new List<PlondsMirrorEntry>
|
||||
{
|
||||
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
|
||||
{
|
||||
mirrors.Add(new PlondsMirrorEntry(
|
||||
"s3",
|
||||
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
|
||||
}
|
||||
|
||||
return new PlondsAssetEntry(
|
||||
AssetId: fileName,
|
||||
FileName: fileName,
|
||||
Sha256: PayloadUtilities.ComputeSha256(assetPath),
|
||||
Size: new FileInfo(assetPath).Length,
|
||||
Mirrors: mirrors);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
@@ -1,237 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsReleaseIndexOptions(
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string Channel,
|
||||
string PlatformSummariesDirectory,
|
||||
string OutputRoot,
|
||||
string PrivateKeyPath);
|
||||
@@ -1,38 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Plonds.Core.Security;
|
||||
|
||||
public sealed class RsaFileSigner
|
||||
{
|
||||
public string SignFile(string filePath, string privateKeyPath, string? outputPath = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Manifest file not found.", filePath);
|
||||
}
|
||||
|
||||
if (!File.Exists(privateKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Private key PEM file not found.", privateKeyPath);
|
||||
}
|
||||
|
||||
outputPath ??= filePath + ".sig";
|
||||
|
||||
var payload = File.ReadAllBytes(filePath);
|
||||
var privateKeyPem = File.ReadAllText(privateKeyPath, Encoding.ASCII);
|
||||
if (string.IsNullOrWhiteSpace(privateKeyPem))
|
||||
{
|
||||
throw new InvalidOperationException("Private key PEM is empty.");
|
||||
}
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(privateKeyPem);
|
||||
var signature = rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(outputPath, Convert.ToBase64String(signature), Encoding.ASCII);
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsAssetEntry(
|
||||
string AssetId,
|
||||
string FileName,
|
||||
string Sha256,
|
||||
long Size,
|
||||
IReadOnlyList<PlondsMirrorEntry> Mirrors);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsChangedFileEntry(
|
||||
string ArchivePath,
|
||||
string Hash,
|
||||
long Size,
|
||||
string HashAlgorithm = "sha256");
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsChannelPointer(
|
||||
string Channel,
|
||||
string Platform,
|
||||
string DistributionId,
|
||||
string Version,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? DistributionPath = null,
|
||||
string? FileMapPath = null);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsComponent(
|
||||
string Id,
|
||||
string Root,
|
||||
string Mode,
|
||||
IReadOnlyList<PlondsFileEntry> Files,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsDistributionInfo(
|
||||
string DistributionId,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset PublishedAt,
|
||||
IReadOnlyList<PlondsComponent> Components,
|
||||
IReadOnlyList<PlondsMirrorAsset> InstallerMirrors,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsFileEntry(
|
||||
string Path,
|
||||
string Op,
|
||||
string ContentHash,
|
||||
string Action,
|
||||
string Hash,
|
||||
long Size,
|
||||
string Mode,
|
||||
string? ObjectKey = null,
|
||||
string? Compression = null,
|
||||
string? PatchBaseHash = null,
|
||||
string? PatchObjectKey = null);
|
||||
|
||||
string HashAlgorithm = "sha256");
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsFileMap(
|
||||
string FormatVersion,
|
||||
string DistributionId,
|
||||
string SourceVersion,
|
||||
string TargetVersion,
|
||||
string Platform,
|
||||
IReadOnlyList<PlondsComponent> Components,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsManifest(
|
||||
string FormatVersion,
|
||||
string ReleaseTag,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<PlondsAssetEntry> Assets);
|
||||
string CurrentVersion,
|
||||
string PreviousVersion,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CompareMethod,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
string? HashAlgorithm,
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsMetadataCatalog(
|
||||
string ProtocolName,
|
||||
string ProtocolVersion,
|
||||
string StorageRoot,
|
||||
string MetaRoot,
|
||||
IReadOnlyList<PlondsChannelPointer> Latest,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsMirrorAsset(
|
||||
string Platform,
|
||||
string Arch,
|
||||
string Url,
|
||||
string? FileName = null,
|
||||
string? Sha256 = null,
|
||||
long Size = 0);
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsMirrorEntry(
|
||||
string Type,
|
||||
string Url);
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsReleaseManifest(
|
||||
string FormatVersion,
|
||||
string ReleaseTag,
|
||||
string Version,
|
||||
string Channel,
|
||||
DateTimeOffset GeneratedAt,
|
||||
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);
|
||||
@@ -1,14 +0,0 @@
|
||||
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,7 +0,0 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsSignatureDescriptor(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string Signature);
|
||||
|
||||
@@ -3,23 +3,39 @@ namespace Plonds.Shared;
|
||||
public static class PlondsConstants
|
||||
{
|
||||
public const string ProtocolName = "PLONDS";
|
||||
public const string ProtocolVersion = "1.0";
|
||||
public const string ProtocolVersion = "2.0";
|
||||
public const string FormatVersion = "2.0";
|
||||
|
||||
public const string DefaultApiBasePath = "/api/plonds/v1";
|
||||
public const string DefaultStorageRoot = "sample-data";
|
||||
public const string DefaultMetaRoot = "meta";
|
||||
public const string DefaultRepoRoot = "repo";
|
||||
public const string DefaultInstallersRoot = "installers";
|
||||
public const string ActionAdd = "add";
|
||||
public const string ActionReplace = "replace";
|
||||
public const string ActionReuse = "reuse";
|
||||
public const string ActionDelete = "delete";
|
||||
|
||||
public const string FileObjectMode = "file-object";
|
||||
public const string CompressedObjectMode = "compressed-object";
|
||||
public const string BinaryPatchMode = "binary-patch";
|
||||
public const string CompareMethodFileCompare = "file-compare";
|
||||
public const string CompareMethodCommitAnalyze = "commit-analyze";
|
||||
|
||||
public static readonly string[] SupportedFileModes =
|
||||
public const string HashAlgorithmSha256 = "sha256";
|
||||
public const string HashAlgorithmMd5 = "md5";
|
||||
|
||||
public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
|
||||
|
||||
public static readonly string[] SupportedActions =
|
||||
[
|
||||
FileObjectMode,
|
||||
CompressedObjectMode,
|
||||
BinaryPatchMode
|
||||
ActionAdd,
|
||||
ActionReplace,
|
||||
ActionReuse,
|
||||
ActionDelete
|
||||
];
|
||||
|
||||
public static readonly string[] SupportedHashAlgorithms =
|
||||
[
|
||||
HashAlgorithmSha256,
|
||||
HashAlgorithmMd5
|
||||
];
|
||||
|
||||
public static readonly string[] SupportedCompareMethods =
|
||||
[
|
||||
CompareMethodFileCompare,
|
||||
CompareMethodCommitAnalyze
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Plonds.Shared;
|
||||
|
||||
public enum PlondsFileAction
|
||||
{
|
||||
Add,
|
||||
Replace,
|
||||
Reuse,
|
||||
Delete
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Plonds.Core.Publishing;
|
||||
using Plonds.Core.Security;
|
||||
using Plonds.Core.Publishing;
|
||||
|
||||
return await PlondsCli.RunAsync(args);
|
||||
|
||||
@@ -20,26 +19,14 @@ internal static class PlondsCli
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "generate":
|
||||
RunGenerate(options);
|
||||
return Task.FromResult(0);
|
||||
case "sign":
|
||||
RunSign(options);
|
||||
return Task.FromResult(0);
|
||||
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);
|
||||
case "build-delta-from-commits":
|
||||
RunBuildDeltaFromCommits(options);
|
||||
return Task.FromResult(0);
|
||||
case "build-plonds":
|
||||
RunBuildPlonds(options);
|
||||
case "pack-payload":
|
||||
RunPackPayload(options);
|
||||
return Task.FromResult(0);
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
@@ -54,63 +41,51 @@ internal static class PlondsCli
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunGenerate(Dictionary<string, string> options)
|
||||
private static void RunBuildDelta(Dictionary<string, string> options)
|
||||
{
|
||||
var generator = new PlondsGenerator();
|
||||
var result = generator.Generate(new PlondsGenerateOptions(
|
||||
CurrentVersion: Require(options, "current-version"),
|
||||
CurrentDirectory: Require(options, "current-dir"),
|
||||
var builder = new PlondsDeltaBuilder();
|
||||
var result = builder.Build(new PlondsDeltaBuildOptions(
|
||||
Platform: Require(options, "platform"),
|
||||
CurrentVersion: Require(options, "current-version"),
|
||||
CurrentPayloadZip: Require(options, "current-zip"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
|
||||
PreviousDirectory: Get(options, "previous-dir"),
|
||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||
DistributionId: Get(options, "distribution-id"),
|
||||
RepoBaseUrl: Get(options, "repo-base-url"),
|
||||
FileMapUrl: Get(options, "file-map-url"),
|
||||
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
|
||||
InstallerDirectory: Get(options, "installer-directory"),
|
||||
InstallerBaseUrl: Get(options, "installer-base-url")));
|
||||
|
||||
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
|
||||
Console.WriteLine(result.FileMapPath);
|
||||
}
|
||||
|
||||
private static void RunSign(Dictionary<string, string> options)
|
||||
{
|
||||
var signer = new RsaFileSigner();
|
||||
var signaturePath = signer.SignFile(
|
||||
Require(options, "manifest"),
|
||||
Require(options, "private-key"),
|
||||
Get(options, "output"));
|
||||
Console.WriteLine(signaturePath);
|
||||
}
|
||||
|
||||
private static void RunPublish(Dictionary<string, string> options)
|
||||
{
|
||||
var publisher = new PlondsPublisher();
|
||||
var results = publisher.Publish(new PlondsPublishOptions(
|
||||
Version: Require(options, "version"),
|
||||
AppArtifactsRoot: Require(options, "app-artifacts-root"),
|
||||
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
PrivateKeyPath: Require(options, "private-key"),
|
||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||
BaselineRoot: Get(options, "baseline-root"),
|
||||
RepoBaseUrl: Get(options, "repo-base-url"),
|
||||
InstallerBaseUrl: Get(options, "installer-base-url"),
|
||||
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
|
||||
BaselineVersion: Get(options, "baseline-version"),
|
||||
BaselineRef: Get(options, "baseline-ref"),
|
||||
SourceCommit: Get(options, "source-commit"),
|
||||
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
|
||||
CommitRangeStart: Get(options, "commit-range-start"),
|
||||
CommitRangeEnd: Get(options, "commit-range-end")));
|
||||
BaselinePayloadZip: Get(options, "baseline-zip"),
|
||||
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
|
||||
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
|
||||
}
|
||||
Console.WriteLine($"Built PLONDS delta for {result.Platform}:");
|
||||
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
|
||||
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
|
||||
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
|
||||
Console.WriteLine($" Manifest: {result.ManifestPath}");
|
||||
}
|
||||
|
||||
private static void RunBuildDeltaFromCommits(Dictionary<string, string> options)
|
||||
{
|
||||
var builder = new PlondsCommitDeltaBuilder();
|
||||
var result = builder.Build(new PlondsCommitDeltaBuildOptions(
|
||||
Platform: Require(options, "platform"),
|
||||
CurrentVersion: Require(options, "current-version"),
|
||||
CurrentPayloadZip: Require(options, "current-zip"),
|
||||
OutputRoot: Require(options, "output-dir"),
|
||||
Channel: Require(options, "channel"),
|
||||
BaselineTag: Require(options, "baseline-tag"),
|
||||
CurrentTag: Require(options, "current-tag"),
|
||||
FallbackBaselineZip: Get(options, "fallback-zip"),
|
||||
BaselineVersion: Get(options, "baseline-version"),
|
||||
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
|
||||
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
|
||||
|
||||
Console.WriteLine($"Built PLONDS commit-delta for {result.Platform}:");
|
||||
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
|
||||
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
|
||||
Console.WriteLine($" FellBackToFileCompare: {result.FellBackToFileCompare}");
|
||||
Console.WriteLine($" ChangedSourceFiles: {result.ChangedSourceFiles.Count}");
|
||||
Console.WriteLine($" MappedArtifactFiles: {result.MappedArtifactFiles.Count}");
|
||||
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
|
||||
Console.WriteLine($" Manifest: {result.ManifestPath}");
|
||||
}
|
||||
|
||||
private static void RunPackPayload(Dictionary<string, string> options)
|
||||
@@ -121,56 +96,6 @@ internal static class PlondsCli
|
||||
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,
|
||||
StaticOutputRoot: Get(options, "static-output-dir"),
|
||||
UpdateBaseUrl: Get(options, "update-base-url")));
|
||||
|
||||
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 RunBuildPlonds(Dictionary<string, string> options)
|
||||
{
|
||||
var builder = new PlondsManifestBuilder();
|
||||
var manifestPath = builder.Build(new PlondsBuildOptions(
|
||||
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);
|
||||
@@ -212,12 +137,8 @@ internal static class PlondsCli
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("PLONDS Tool");
|
||||
Console.WriteLine(" build-delta --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> [--channel <ch>] [--baseline-version <v>] [--baseline-zip <file>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
|
||||
Console.WriteLine(" build-delta-from-commits --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> --channel <ch> --baseline-tag <tag> --current-tag <tag> [--fallback-zip <file>] [--baseline-version <v>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
|
||||
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] [--static-output-dir <dir>] [--update-base-url <url>]");
|
||||
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
|
||||
Console.WriteLine(" build-plonds --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