ci.plonds

This commit is contained in:
lincube
2026-04-21 16:12:47 +08:00
parent d31aa90b9c
commit 8568fdf16b
12 changed files with 1662 additions and 437 deletions

View File

@@ -1,4 +1,4 @@
name: Release name: Release
on: on:
push: push:
@@ -15,6 +15,23 @@ on:
required: false required: false
type: boolean type: boolean
default: false default: false
incremental_strategy:
description: 'Incremental strategy'
required: false
type: choice
default: release-payload
options:
- release-payload
- commit-range
publish_incremental_release:
description: 'Publish as incremental release'
required: false
type: boolean
default: true
baseline_ref:
description: 'Optional baseline tag/version/commit'
required: false
type: string
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
@@ -32,6 +49,11 @@ jobs:
checkout_ref: ${{ steps.version.outputs.checkout_ref }} checkout_ref: ${{ steps.version.outputs.checkout_ref }}
steps: steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info - name: Get release info
id: version id: version
run: | run: |
@@ -47,7 +69,11 @@ jobs:
else else
TAG="v${RAW_TAG}" TAG="v${RAW_TAG}"
fi fi
CHECKOUT_REF="${GITHUB_SHA}" if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
CHECKOUT_REF="refs/tags/${TAG}"
else
CHECKOUT_REF="${GITHUB_SHA}"
fi
fi fi
VERSION="${TAG#v}" VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
@@ -120,14 +146,18 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true ` -p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true ` -p:EnableCompressionInSingleFile=true `
-p:DebugType=none ` -p:DebugType=none `
-p:DebugSymbols=false -p:DebugSymbols=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed" Write-Error "Launcher AOT publish failed"
exit 1 exit 1
} }
# 閺勫墽銇氶崣鎴濈缂佹挻鐏? # é<EFBFBD>„剧ã<EFBFBD>šé<EFBFBD>™æˆ<EFBFBD>ç«·ç¼<EFBFBD>æ´ç<EFBFBD>?
Write-Host "Launcher published to: $launcherPublishDir" Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1 $exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) { if ($exeFile) {
@@ -384,7 +414,7 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for Linux x64..." echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-linux-x64 \ -o ./publish/launcher-linux-x64 \
@@ -395,13 +425,17 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false -p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed" echo "Launcher AOT publish failed"
exit 1 exit 1
fi fi
echo "Launcher published to: ./publish/launcher-linux-x64" echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/ ls -lh ./publish/launcher-linux-x64/
@@ -587,7 +621,7 @@ jobs:
- name: Publish Launcher (AOT) - name: Publish Launcher (AOT)
run: | run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..." echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \ -c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \ -o ./publish/launcher-macos-${{ matrix.arch }} \
@@ -598,13 +632,17 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \ -p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \ -p:EnableCompressionInSingleFile=true \
-p:DebugType=none \ -p:DebugType=none \
-p:DebugSymbols=false -p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed" echo "Launcher AOT publish failed"
exit 1 exit 1
fi fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}" echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/ ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
@@ -737,6 +775,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@@ -816,6 +858,21 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$incrementalStrategy = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.incremental_strategy }}")) {
"${{ github.event.inputs.incremental_strategy }}"
} else {
"release-payload"
}
$publishIncrementalRelease = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.publish_incremental_release }}")) {
"${{ github.event.inputs.publish_incremental_release }}"
} else {
"true"
}
$baselineRef = if ("${{ github.event_name }}" -eq "workflow_dispatch") {
"${{ github.event.inputs.baseline_ref }}"
} else {
""
}
./scripts/Publish-Plonds.ps1 ` ./scripts/Publish-Plonds.ps1 `
-Version $env:VERSION ` -Version $env:VERSION `
@@ -826,7 +883,14 @@ jobs:
-Channel "stable" ` -Channel "stable" `
-S3Endpoint $env:S3_ENDPOINT ` -S3Endpoint $env:S3_ENDPOINT `
-S3Bucket $env:S3_BUCKET ` -S3Bucket $env:S3_BUCKET `
-S3Region $env:S3_REGION -S3Region $env:S3_REGION `
-IncrementalStrategy $incrementalStrategy `
-PublishIncrementalRelease $publishIncrementalRelease `
-BaselineRef $baselineRef `
-GitHubRepository "${{ github.repository }}" `
-GitHubTag "${{ needs.prepare.outputs.tag }}" `
-MirrorInstallersToS3 "false" `
-UploadMetaToS3 "false"
- name: Upload PLONDS assets - name: Upload PLONDS assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -872,7 +936,7 @@ jobs:
echo "Organizing artifacts..." echo "Organizing artifacts..."
mkdir -p release-files mkdir -p release-files
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" \) -exec cp -v {} release-files/ \; find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" -o -name "plonds-payload-*.zip" \) -exec cp -v {} release-files/ \;
echo "" echo ""
echo "Files ready for release:" echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files" ls -lh release-files/ || echo "No files found in release-files"
@@ -906,7 +970,15 @@ jobs:
Installation: Double-click the .exe file and follow the wizard. Installation: Double-click the .exe file and follow the wizard.
### Incremental Update Assets`n - **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**`n - **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**`n - **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**`n`n ### Legacy Fallback Assets ### Incremental Update Assets
- **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**
- **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**
- **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**
- **plonds-payload-windows-x64.zip**
- **plonds-payload-windows-x86.zip**
- **plonds-payload-linux-x64.zip**
### Legacy Fallback Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip** - **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip** - **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip** - **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
@@ -923,3 +995,42 @@ jobs:
See commits for changes. See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
publish-plonds-meta:
needs: [ prepare, publish-plonds, github-release ]
runs-on: ubuntu-latest
permissions:
contents: read
env:
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
steps:
- name: Download PLONDS artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/plonds
pattern: plonds-assets
- name: Publish PLONDS meta to S3
if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }}
shell: bash
run: |
set -euo pipefail
meta_dir="$(find artifacts/plonds -type d -path '*/published/meta' | head -n 1)"
if [ -z "${meta_dir}" ]; then
echo "Unable to locate published/meta inside PLONDS artifacts"
exit 1
fi
echo "Publishing PLONDS meta from ${meta_dir}"
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 cp "$meta_dir" "s3://$S3_BUCKET/lanmountain/update/meta/" --recursive --only-show-errors --no-progress

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
@@ -17,6 +18,7 @@ namespace LanMountainDesktop.Services;
public sealed class PlondsReleaseUpdateService : IDisposable public sealed class PlondsReleaseUpdateService : IDisposable
{ {
private const string DefaultApiBasePath = "/api/plonds/v1"; private const string DefaultApiBasePath = "/api/plonds/v1";
private const int MaxTransientRetryAttempts = 3;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient; private readonly bool _ownsHttpClient;
@@ -71,6 +73,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
var normalizedCurrentVersion = NormalizeVersion(currentVersion); var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion); var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
var endpoint = ResolveEndpoint(); var endpoint = ResolveEndpoint();
var latestVersionText = "-";
if (string.IsNullOrWhiteSpace(endpoint)) if (string.IsNullOrWhiteSpace(endpoint))
{ {
@@ -78,7 +81,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
Success: false, Success: false,
IsUpdateAvailable: false, IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText, CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-", LatestVersionText: latestVersionText,
Release: null, Release: null,
PreferredAsset: null, PreferredAsset: null,
ErrorMessage: "PLONDS endpoint is not configured.", ErrorMessage: "PLONDS endpoint is not configured.",
@@ -89,8 +92,6 @@ public sealed class PlondsReleaseUpdateService : IDisposable
{ {
var apiBasePath = ResolveApiBasePath(); var apiBasePath = ResolveApiBasePath();
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata"); var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(includePrerelease); var channelId = ResolveChannelId(includePrerelease);
var platform = ResolvePlatform(); var platform = ResolvePlatform();
var latestUrl = BuildApiUrl( var latestUrl = BuildApiUrl(
@@ -98,12 +99,14 @@ public sealed class PlondsReleaseUpdateService : IDisposable
apiBasePath, apiBasePath,
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}"); $"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
JsonElement latestNode; _ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false);
try
{ var latestDescriptor = await GetLatestDescriptorAsync(
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false); latestUrl,
} allowNoUpdateResponse: true,
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase)) cancellationToken).ConfigureAwait(false);
if (latestDescriptor is null)
{ {
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
@@ -116,35 +119,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
ForceMode: isForce); ForceMode: isForce);
} }
var latestVersionText = ReadString(latestNode, "version") ?? "-"; latestVersionText = latestDescriptor.VersionText;
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null) var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion;
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS latest distribution version is invalid.",
ForceMode: isForce);
}
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS latest distribution id is missing.",
ForceMode: isForce);
}
var hasUpdate = latestVersion > normalizedCurrentVersion;
if (!isForce && !hasUpdate) if (!isForce && !hasUpdate)
{ {
return new UpdateCheckResult( return new UpdateCheckResult(
@@ -158,58 +134,67 @@ public sealed class PlondsReleaseUpdateService : IDisposable
ForceMode: false); ForceMode: false);
} }
var distributionUrl = BuildApiUrl( var distribution = await ResolveDistributionAsync(
endpoint, endpoint,
apiBasePath, apiBasePath,
$"distributions/{Uri.EscapeDataString(distributionId)}"); latestUrl,
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false); latestDescriptor,
channelId,
platform,
cancellationToken).ConfigureAwait(false);
var assets = ResolveInstallerAssets(distributionNode); latestVersionText = distribution.Latest.VersionText;
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow; var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow;
var release = new GitHubReleaseInfo( var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}", TagName: $"v{distribution.Latest.VersionText}",
Name: $"PLONDS Distribution {latestVersionText}", Name: $"PLONDS Distribution {distribution.Latest.VersionText}",
IsPrerelease: includePrerelease, IsPrerelease: includePrerelease,
IsDraft: false, IsDraft: false,
PublishedAt: publishedAt, PublishedAt: publishedAt,
Assets: assets); Assets: distribution.Assets);
return new UpdateCheckResult( return new UpdateCheckResult(
Success: true, Success: true,
IsUpdateAvailable: true, IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText, CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText, LatestVersionText: distribution.Latest.VersionText,
Release: release, Release: release,
PreferredAsset: SelectPreferredInstallerAsset(assets), PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets),
ErrorMessage: null, ErrorMessage: null,
ForceMode: isForce, ForceMode: isForce,
PlondsPayload: payload); PlondsPayload: distribution.Payload);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
throw; throw;
} }
catch (Exception ex) catch (PlondsRequestException ex)
{ {
AppLogger.Warn(
"PLONDS",
$"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}",
ex);
return new UpdateCheckResult( return new UpdateCheckResult(
Success: false, Success: false,
IsUpdateAvailable: false, IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText, CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-", LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: $"PLONDS {GetStageName(ex.Stage)} failed: {ex.Message}",
ForceMode: isForce);
}
catch (Exception ex)
{
AppLogger.Warn("PLONDS", "PLONDS request failed with an unexpected error.", ex);
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null, Release: null,
PreferredAsset: null, PreferredAsset: null,
ErrorMessage: $"PLONDS request failed: {ex.Message}", ErrorMessage: $"PLONDS request failed: {ex.Message}",
@@ -217,7 +202,125 @@ public sealed class PlondsReleaseUpdateService : IDisposable
} }
} }
private async Task<JsonElement> GetJsonNodeAsync(string url, CancellationToken cancellationToken) private async Task<LatestDescriptor?> GetLatestDescriptorAsync(
string latestUrl,
bool allowNoUpdateResponse,
CancellationToken cancellationToken)
{
try
{
var latestNode = await GetJsonNodeWithRetryAsync(
latestUrl,
PlondsCheckStage.Latest,
cancellationToken).ConfigureAwait(false);
return ParseLatestDescriptor(latestNode);
}
catch (PlondsRequestException ex)
when (allowNoUpdateResponse &&
ex.Stage == PlondsCheckStage.Latest &&
ex.StatusCode == HttpStatusCode.NoContent)
{
return null;
}
}
private async Task<DistributionDescriptor> ResolveDistributionAsync(
string endpoint,
string apiBasePath,
string latestUrl,
LatestDescriptor latest,
string channelId,
string platform,
CancellationToken cancellationToken)
{
var currentLatest = latest;
var hasRefreshedLatest = false;
while (true)
{
var distributionUrl = BuildApiUrl(
endpoint,
apiBasePath,
$"distributions/{Uri.EscapeDataString(currentLatest.DistributionId)}");
try
{
var distributionNode = await GetJsonNodeWithRetryAsync(
distributionUrl,
PlondsCheckStage.Distribution,
cancellationToken).ConfigureAwait(false);
if (TryCreateDistributionDescriptor(
distributionNode,
currentLatest,
channelId,
platform,
out var descriptor,
out var descriptorError))
{
return descriptor;
}
if (hasRefreshedLatest || descriptorError is null || !IsRecoverableDistributionError(descriptorError))
{
throw descriptorError ?? new PlondsRequestException(
PlondsCheckStage.PayloadParse,
"PLONDS distribution payload is incomplete.");
}
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution '{currentLatest.DistributionId}' is incomplete. Refreshing latest pointer once before failing.");
}
catch (PlondsRequestException ex) when (!hasRefreshedLatest && IsRecoverableDistributionError(ex))
{
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution fetch for '{currentLatest.DistributionId}' failed during {GetStageName(ex.Stage)}. Refreshing latest pointer once. Details: {ex.Message}");
}
hasRefreshedLatest = true;
currentLatest = await GetLatestDescriptorAsync(
latestUrl,
allowNoUpdateResponse: false,
cancellationToken).ConfigureAwait(false)
?? throw new PlondsRequestException(
PlondsCheckStage.Latest,
"PLONDS latest pointer disappeared while recovering the distribution payload.");
}
}
private async Task<JsonElement> GetJsonNodeWithRetryAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{
PlondsRequestException? lastError = null;
for (var attempt = 1; attempt <= MaxTransientRetryAttempts; attempt++)
{
try
{
return await GetJsonNodeAsync(url, stage, cancellationToken).ConfigureAwait(false);
}
catch (PlondsRequestException ex) when (attempt < MaxTransientRetryAttempts && ex.IsTransient)
{
lastError = ex;
AppLogger.Warn(
"PLONDS",
$"PLONDS {GetStageName(stage)} attempt {attempt}/{MaxTransientRetryAttempts} failed. Retrying shortly. Details: {ex.Message}");
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
}
}
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
}
private async Task<JsonElement> GetJsonNodeAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken(); var token = ResolveToken();
@@ -226,22 +329,127 @@ public sealed class PlondsReleaseUpdateService : IDisposable
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
} }
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); HttpResponseMessage response;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); try
if (!response.IsSuccessStatusCode)
{ {
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}"); response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new PlondsRequestException(stage, "Request timed out.", isTransient: true, innerException: ex);
}
catch (HttpRequestException ex)
{
throw new PlondsRequestException(stage, $"Network error: {ex.Message}", isTransient: true, innerException: ex);
} }
using var document = JsonDocument.Parse(body); using (response)
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{ {
return content.Clone(); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent)
{
throw new PlondsRequestException(
stage,
"HTTP 204: no content.",
statusCode: response.StatusCode,
isTransient: false);
}
if (!response.IsSuccessStatusCode)
{
throw new PlondsRequestException(
stage,
$"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}",
statusCode: response.StatusCode,
isTransient: IsTransientStatusCode(response.StatusCode));
}
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{
return content.Clone();
}
return root.Clone();
}
catch (JsonException ex)
{
throw new PlondsRequestException(
stage,
$"Invalid JSON response: {ex.Message}",
isTransient: IsLikelyIncompleteJson(body),
innerException: ex);
}
}
}
private static LatestDescriptor ParseLatestDescriptor(JsonElement latestNode)
{
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
throw new PlondsRequestException(
PlondsCheckStage.Latest,
$"PLONDS latest distribution version is invalid: '{latestVersionText}'.");
} }
return root.Clone(); var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
throw new PlondsRequestException(
PlondsCheckStage.Latest,
"PLONDS latest distribution id is missing.");
}
return new LatestDescriptor(distributionId, latestVersionText, latestVersion);
}
private static bool TryCreateDistributionDescriptor(
JsonElement distributionNode,
LatestDescriptor latest,
string channelId,
string platform,
out DistributionDescriptor descriptor,
out PlondsRequestException? error)
{
descriptor = default!;
error = null;
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(
distributionNode,
latest.DistributionId,
channelId,
platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
error = new PlondsRequestException(
PlondsCheckStage.PayloadParse,
"PLONDS distribution response does not expose downloadable update assets.");
return false;
}
descriptor = new DistributionDescriptor(latest, distributionNode, assets, payload);
return true;
}
private static bool IsRecoverableDistributionError(PlondsRequestException error)
{
if (error.Stage == PlondsCheckStage.PayloadParse)
{
return true;
}
return error.Stage == PlondsCheckStage.Distribution &&
(error.StatusCode == HttpStatusCode.NotFound ||
error.StatusCode == HttpStatusCode.RequestTimeout ||
error.StatusCode == HttpStatusCode.TooManyRequests ||
error.StatusCode is >= HttpStatusCode.InternalServerError);
} }
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode) private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
@@ -258,7 +466,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
continue; continue;
} }
var name = ReadString(installerNode, "name"); var name = ReadString(installerNode, "name")
?? ReadString(installerNode, "fileName");
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl"); var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{ {
@@ -593,4 +802,91 @@ public sealed class PlondsReleaseUpdateService : IDisposable
return value[..maxLength]; return value[..maxLength];
} }
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
{
return statusCode == HttpStatusCode.RequestTimeout ||
statusCode == HttpStatusCode.TooManyRequests ||
statusCode >= HttpStatusCode.InternalServerError;
}
private static bool IsLikelyIncompleteJson(string? body)
{
if (string.IsNullOrWhiteSpace(body))
{
return true;
}
var trimmed = body.TrimEnd();
if (trimmed.Length == 0)
{
return true;
}
var last = trimmed[^1];
return last != '}' && last != ']';
}
private static TimeSpan GetRetryDelay(int attempt)
{
return attempt switch
{
1 => TimeSpan.FromMilliseconds(350),
2 => TimeSpan.FromMilliseconds(900),
_ => TimeSpan.FromMilliseconds(1500)
};
}
private static string GetStageName(PlondsCheckStage stage)
{
return stage switch
{
PlondsCheckStage.Metadata => "metadata",
PlondsCheckStage.Latest => "latest",
PlondsCheckStage.Distribution => "distribution",
PlondsCheckStage.PayloadParse => "payload-parse",
_ => "unknown"
};
}
private enum PlondsCheckStage
{
Metadata,
Latest,
Distribution,
PayloadParse
}
private sealed record LatestDescriptor(
string DistributionId,
string VersionText,
Version Version);
private sealed record DistributionDescriptor(
LatestDescriptor Latest,
JsonElement DistributionNode,
IReadOnlyList<GitHubReleaseAsset> Assets,
PlondsUpdatePayload Payload);
private sealed class PlondsRequestException : Exception
{
public PlondsRequestException(
PlondsCheckStage stage,
string message,
HttpStatusCode? statusCode = null,
bool isTransient = false,
Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
StatusCode = statusCode;
IsTransient = isTransient;
}
public PlondsCheckStage Stage { get; }
public HttpStatusCode? StatusCode { get; }
public bool IsTransient { get; }
}
} }

View File

@@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
AppLogger.Warn( AppLogger.Warn(
"UpdateSettings", "UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}"); $"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
} }
return isForce return isForce

View File

@@ -71,6 +71,7 @@ public sealed class UpdateWorkflowService
}; };
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient); private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
private const int MaxPlondsOuterRetryAttempts = 3;
public UpdateWorkflowService(ISettingsFacadeService settingsFacade) public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{ {
@@ -251,7 +252,12 @@ public sealed class UpdateWorkflowService
var payload = checkResult.PlondsPayload; var payload = checkResult.PlondsPayload;
if (payload is null) if (payload is null)
{ {
return new UpdateDownloadResult(false, null, "PLONDS payload is missing."); return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
"PLONDS payload is missing.",
progress,
cancellationToken);
} }
var incomingDir = GetLauncherIncomingDirectory(); var incomingDir = GetLauncherIncomingDirectory();
@@ -264,7 +270,12 @@ public sealed class UpdateWorkflowService
} }
catch (Exception ex) catch (Exception ex)
{ {
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}"); return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
$"Failed to create incoming directory: {ex.Message}",
progress,
cancellationToken);
} }
try try
@@ -279,18 +290,31 @@ public sealed class UpdateWorkflowService
payload.FileMapJson, payload.FileMapJson,
payload.FileMapJsonUrl, payload.FileMapJsonUrl,
fileMapPath, fileMapPath,
"file map",
"filemap-download",
cancellationToken); cancellationToken);
var fileMapSignature = await EnsurePlondsTextResourceAsync( var fileMapSignature = await EnsurePlondsTextResourceAsync(
payload.FileMapSignature, payload.FileMapSignature,
payload.FileMapSignatureUrl, payload.FileMapSignatureUrl,
signaturePath, signaturePath,
"file map signature",
"filemap-download",
cancellationToken); cancellationToken);
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson); IReadOnlyList<PlondsDownloadEntry> downloadEntries;
try
{
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
}
catch (JsonException ex)
{
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
}
if (downloadEntries.Count == 0) if (downloadEntries.Count == 0)
{ {
return new UpdateDownloadResult(false, null, "PLONDS file map does not contain downloadable objects."); throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
} }
var expectedObjectCount = downloadEntries.Count; var expectedObjectCount = downloadEntries.Count;
@@ -310,46 +334,13 @@ public sealed class UpdateWorkflowService
continue; continue;
} }
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex); var objectInfo = await EnsurePlondsObjectAsync(
var destinationDirectory = Path.GetDirectoryName(destinationPath); entry,
if (!string.IsNullOrWhiteSpace(destinationDirectory)) objectsDir,
{ downloadThreads,
Directory.CreateDirectory(destinationDirectory);
}
if (File.Exists(destinationPath))
{
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
continue;
}
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
cancellationToken); cancellationToken);
if (!downloadResult.Success) objectResults.Add(objectInfo);
{
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
}
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++; completedItems++;
progress?.Report((double)completedItems / totalSteps); progress?.Report((double)completedItems / totalSteps);
} }
@@ -390,8 +381,20 @@ public sealed class UpdateWorkflowService
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex); var stage = ex is PlondsDownloadException plondsException
return new UpdateDownloadResult(false, null, ex.Message); ? plondsException.Stage
: "payload-parse";
var message = ex is PlondsDownloadException
? ex.Message
: $"PLONDS incremental payload failed unexpectedly: {ex.Message}";
AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex);
return await HandlePlondsDeltaFailureAsync(
checkResult,
stage,
message,
progress,
cancellationToken);
} }
} }
@@ -420,6 +423,125 @@ public sealed class UpdateWorkflowService
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
} }
private async Task<UpdateDownloadResult> DownloadFullInstallerAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress,
CancellationToken cancellationToken,
bool forceRedownload)
{
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (!forceRedownload &&
existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(
true,
existingPending.InstallerPath,
null,
verifyResult.HashMatched,
verifyResult.ExpectedHash,
verifyResult.ActualHash);
}
AppLogger.Warn(
"UpdateWorkflow",
$"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
ClearPendingUpdate();
state = _settingsFacade.Update.Get();
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
private async Task<UpdateDownloadResult> HandlePlondsDeltaFailureAsync(
UpdateCheckResult checkResult,
string stage,
string errorMessage,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage)
? $"PLONDS {stage} failed."
: $"PLONDS {stage} failed: {errorMessage}";
if (checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, normalizedMessage);
}
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}");
var fallbackResult = await DownloadFullInstallerAsync(
checkResult,
progress,
cancellationToken,
forceRedownload: false);
if (fallbackResult.Success)
{
return fallbackResult;
}
var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage)
? normalizedMessage
: $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}";
return new UpdateDownloadResult(false, null, combinedMessage);
}
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex) private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
{ {
var normalizedHash = objectHashHex.Trim().ToLowerInvariant(); var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
@@ -431,6 +553,8 @@ public sealed class UpdateWorkflowService
string? inlineContent, string? inlineContent,
string? sourceUrl, string? sourceUrl,
string destinationPath, string destinationPath,
string resourceName,
string stage,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!string.IsNullOrWhiteSpace(inlineContent)) if (!string.IsNullOrWhiteSpace(inlineContent))
@@ -441,20 +565,131 @@ public sealed class UpdateWorkflowService
if (string.IsNullOrWhiteSpace(sourceUrl)) if (string.IsNullOrWhiteSpace(sourceUrl))
{ {
throw new InvalidOperationException("PLONDS payload does not contain a file map source."); throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
} }
var downloadResult = await PlondsDownloadService.DownloadAsync( Exception? lastError = null;
sourceUrl, for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
destinationPath,
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{ {
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}"); var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (downloadResult.Success)
{
try
{
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
}
catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts)
{
lastError = ex;
}
}
else
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}.");
}
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
}
} }
return await File.ReadAllTextAsync(destinationPath, cancellationToken); throw new PlondsDownloadException(
stage,
$"Failed to download PLONDS {resourceName} from {sourceUrl}.",
lastError);
}
private static async Task<PlondsDownloadedObjectInfo> EnsurePlondsObjectAsync(
PlondsDownloadEntry entry,
string objectsDirectory,
int downloadThreads,
CancellationToken cancellationToken)
{
var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
if (!string.IsNullOrWhiteSpace(existingHash))
{
DeleteFileIfExists(destinationPath);
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var allowForcedRedownload = true;
Exception? lastError = null;
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
{
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
cancellationToken);
if (!downloadResult.Success)
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}.");
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
DeleteFileIfExists(destinationPath);
var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? "<missing>"}";
lastError = new InvalidOperationException(mismatchMessage);
if (allowForcedRedownload)
{
allowForcedRedownload = false;
AppLogger.Warn(
"UpdateWorkflow",
$"{mismatchMessage}. Removing the bad object and forcing one clean re-download.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException("object-verify", mismatchMessage, lastError);
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
} }
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson) private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
@@ -628,6 +863,31 @@ public sealed class UpdateWorkflowService
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
} }
private static void DeleteFileIfExists(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best effort cleanup only. The caller still verifies the resulting payload before it is applied.
}
}
private static TimeSpan GetPlondsRetryDelay(int attempt)
{
return attempt switch
{
1 => TimeSpan.FromMilliseconds(350),
2 => TimeSpan.FromMilliseconds(900),
_ => TimeSpan.FromMilliseconds(1500)
};
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{ {
if (node.ValueKind == JsonValueKind.Object) if (node.ValueKind == JsonValueKind.Object)
@@ -742,6 +1002,17 @@ public sealed class UpdateWorkflowService
string DownloadUrl, string DownloadUrl,
string ObjectHashHex); string ObjectHashHex);
private sealed class PlondsDownloadException : Exception
{
public PlondsDownloadException(string stage, string message, Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
}
public string Stage { get; }
}
private sealed record PlondsDownloadedObjectInfo( private sealed record PlondsDownloadedObjectInfo(
string ComponentId, string ComponentId,
string RelativePath, string RelativePath,
@@ -876,53 +1147,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
} }
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) return await DownloadFullInstallerAsync(
{ checkResult,
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
}
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress, progress,
cancellationToken); cancellationToken,
forceRedownload: false);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
} }
public async Task<UpdateDownloadResult> RedownloadReleaseAsync( public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
@@ -938,58 +1167,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
} }
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) return await DownloadFullInstallerAsync(
{ checkResult,
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
}
ClearPendingUpdate();
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
state = _settingsFacade.Update.Get();
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress, progress,
cancellationToken); cancellationToken,
forceRedownload: true);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
} }
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync() public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()

View File

@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
return; return;
} }
if (result.PreferredAsset is null) if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{ {
UpdateStatus = isForce UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.") ? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
@@ -2050,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))] [RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync() private async Task RedownloadUpdateAsync()
{ {
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null) if (_lastCheckResult is null ||
!_lastCheckResult.Success ||
!_lastCheckResult.IsUpdateAvailable ||
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
{ {
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading."); UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return; return;
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateDownloadResult downloadResult; UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster) // Prefer delta update if available (smaller download, faster)
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release)) if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{ {
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update..."); UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress); downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success) if (!downloadResult.Success && result.PlondsPayload is null)
{ {
// Delta download failed, fall back to full installer // Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer."); AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");

View File

@@ -1,10 +1,10 @@
# PLONDS Skeleton # PLONDS 骨架
Penguin Logistics Online Network Distribution System, or PLONDS, is the standalone update-distribution skeleton for LanMountainDesktop. Penguin Logistics Online Network Distribution System(企鹅物流在线网络分发系统),简称 PLONDS LanMountainDesktop 的独立更新分发骨架。
This directory is intentionally isolated from the main app and Launcher. It contains only the new distribution protocol, a thin read-only API, and sample S3-style metadata files. 本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API以及示例 S3 风格的元数据文件。
## Directory Layout ## 目录结构
```text ```text
PenguinLogisticsOnlineNetworkDistributionSystem/ PenguinLogisticsOnlineNetworkDistributionSystem/
@@ -22,72 +22,72 @@ PenguinLogisticsOnlineNetworkDistributionSystem/
distributions/ distributions/
``` ```
## Projects ## 项目说明
- `Plonds.Shared` provides protocol constants and models. - `Plonds.Shared` 提供协议常量和数据模型。
- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration. - `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool. - `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3. - `Plonds.Api` 是一个轻量级只读 API从类似 S3 布局的本地文件夹中读取元数据。
## Architecture ## 架构设计
PLONDS is intentionally built around a single C# implementation stack so the protocol and publish behavior do not drift across languages. PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
```text ```text
Host App 宿主应用
-> checks updates, downloads objects, stages incoming payload -> 检查更新、下载对象、暂存传入的负载
Launcher 启动器
-> verifies signature, applies file map, switches deployment, rolls back -> 验证签名、应用文件映射、切换部署、回滚
PLONDS.Api PLONDS.Api
-> read-only metadata projection for clients -> 面向客户端的只读元数据投影
PLONDS.Tool PLONDS.Tool
-> CI/release command surface -> CI/发布命令界面
PLONDS.Core PLONDS.Core
-> hash/diff/object-repo/sign/publish implementation -> 哈希/差异/对象仓库/签名/发布实现
PLONDS.Shared PLONDS.Shared
-> protocol constants and DTOs -> 协议常量和 DTO
``` ```
Rules for v1: ## v1 规则
- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts. - 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience. - `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
- Host keeps download responsibility. - 宿主应用保留下载职责。
- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility. - 启动器保留应用、原子切换、快照和回滚职责。
## Storage Layout ## 存储布局
The first version keeps one fixed object root: 第一版本保持固定的对象根目录:
```text ```text
lanmountain/update/ lanmountain/update/
repo/sha256/<prefix>/<hash> repo/sha256/<前缀>/<哈希>
meta/channels/<channel>/<platform>/latest.json meta/channels/<频道>/<平台>/latest.json
meta/distributions/<distributionId>.json meta/distributions/<分发ID>.json
installers/<platform>/<version>/... installers/<平台>/<版本>/...
``` ```
Planned but not enabled in v1: 已规划但 v1 中未启用:
```text ```text
lanmountain/update/repo-compressed/<algo>/<prefix>/<hash> lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
lanmountain/update/patches/<algo>/<baseHash>/<targetHash> lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
``` ```
## Public Endpoints ## 公共接口
The API base path is `/api/plonds/v1`. API 基础路径为 `/api/plonds/v1`
- `GET /healthz` - `GET /healthz` - 健康检查
- `GET /api/plonds/v1/metadata` - `GET /api/plonds/v1/metadata` - 获取元数据目录
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
- `GET /api/plonds/v1/distributions/{distributionId}` - `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
## Local Run ## 本地运行
```powershell ```powershell
dotnet run --project src/Plonds.Api dotnet run --project src/Plonds.Api
``` ```
By default the API reads metadata from `sample-data`. 默认情况下API 从 `sample-data` 读取元数据。

View File

@@ -13,4 +13,11 @@ public sealed record PlondsGenerateOptions(
string? FileMapUrl = null, string? FileMapUrl = null,
string? FileMapSignatureUrl = null, string? FileMapSignatureUrl = null,
string? InstallerDirectory = null, string? InstallerDirectory = null,
string? InstallerBaseUrl = 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

@@ -42,11 +42,16 @@ public sealed class PlondsGenerator
Directory.CreateDirectory(metaDistributionRoot); Directory.CreateDirectory(metaDistributionRoot);
Directory.CreateDirectory(metaChannelRoot); Directory.CreateDirectory(metaChannelRoot);
var previousManifest = ScanDirectory(previousDirectory); var previousManifest = options.IsFullPayloadRelease
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: ScanDirectory(previousDirectory);
var currentManifest = ScanDirectory(currentDirectory); var currentManifest = ScanDirectory(currentDirectory);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl); var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl); var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow; var publishedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var fileMap = new FileMapDocument( var fileMap = new FileMapDocument(
FormatVersion: "1.0", FormatVersion: "1.0",
@@ -69,7 +74,14 @@ public sealed class PlondsGenerator
Metadata: new Dictionary<string, string> Metadata: new Dictionary<string, string>
{ {
["protocol"] = "PLONDS", ["protocol"] = "PLONDS",
["mode"] = "file-object" ["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( var distribution = new DistributionDocument(
@@ -83,7 +95,17 @@ public sealed class PlondsGenerator
Components: fileMap.Components, Components: fileMap.Components,
InstallerMirrors: installerMirrors, InstallerMirrors: installerMirrors,
Capabilities: ["file-object"], Capabilities: ["file-object"],
Metadata: new Dictionary<string, string> { ["protocol"] = "PLONDS" }); 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( var latest = new LatestPointerDocument(
DistributionId: distributionId, DistributionId: distributionId,
@@ -225,6 +247,7 @@ public sealed class PlondsGenerator
Platform: platform, Platform: platform,
Arch: ResolveArch(platform), Arch: ResolveArch(platform),
Url: url, Url: url,
Name: fileName,
FileName: fileName, FileName: fileName,
Sha256: ComputeSha256(destinationPath), Sha256: ComputeSha256(destinationPath),
Size: new FileInfo(destinationPath).Length)); Size: new FileInfo(destinationPath).Length));
@@ -345,6 +368,7 @@ public sealed class PlondsGenerator
string Platform, string Platform,
string Arch, string Arch,
string? Url, string? Url,
string? Name,
string? FileName, string? FileName,
string? Sha256, string? Sha256,
long Size); long Size);

View File

@@ -9,4 +9,11 @@ public sealed record PlondsPublishOptions(
string Channel = "stable", string Channel = "stable",
string? BaselineRoot = null, string? BaselineRoot = null,
string? RepoBaseUrl = null, string? RepoBaseUrl = null,
string? InstallerBaseUrl = 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

@@ -82,7 +82,7 @@ public sealed class PlondsPublisher
CurrentDirectory: currentAppDirectory, CurrentDirectory: currentAppDirectory,
Platform: config.Platform, Platform: config.Platform,
OutputRoot: options.OutputRoot, OutputRoot: options.OutputRoot,
PreviousVersion: previousVersion, PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
PreviousDirectory: previousDirectory, PreviousDirectory: previousDirectory,
Channel: options.Channel, Channel: options.Channel,
DistributionId: distributionId, DistributionId: distributionId,
@@ -90,7 +90,14 @@ public sealed class PlondsPublisher
FileMapUrl: fileMapUrl, FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl, FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory, InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl)); 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); _signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);

View File

@@ -86,7 +86,14 @@ internal static class PlondsCli
Channel: Get(options, "channel", "stable") ?? "stable", Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"), BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"), RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-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")));
foreach (var result in results) foreach (var result in results)
{ {

File diff suppressed because it is too large Load Diff