feat..去除了冗余的字体文件,又修改了PLONDS系统

This commit is contained in:
lincube
2026-05-30 11:56:50 +08:00
parent d004088601
commit 6a650873bc
61 changed files with 1849 additions and 3147 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -1,9 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsReleaseIndexOptions(
string ReleaseTag,
string Version,
string Channel,
string PlatformSummariesDirectory,
string OutputRoot,
string PrivateKeyPath);