diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72bc3b3..5725440 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release on: push: @@ -15,6 +15,23 @@ on: required: false type: boolean 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: DOTNET_VERSION: '10.0.x' @@ -32,6 +49,11 @@ jobs: checkout_ref: ${{ steps.version.outputs.checkout_ref }} steps: + - name: Checkout repository metadata + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Get release info id: version run: | @@ -47,7 +69,11 @@ jobs: else TAG="v${RAW_TAG}" 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 VERSION="${TAG#v}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" @@ -120,14 +146,18 @@ jobs: -p:IncludeNativeLibrariesForSelfExtract=true ` -p:EnableCompressionInSingleFile=true ` -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) { Write-Error "Launcher AOT publish failed" exit 1 } - # 閺勫墽銇氶崣鎴濈缂佹挻鐏? + # 鏄剧ず鍙戝竷缁撴? Write-Host "Launcher published to: $launcherPublishDir" $exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1 if ($exeFile) { @@ -384,7 +414,7 @@ jobs: - name: Publish Launcher (AOT) run: | echo "Publishing Launcher with AOT for Linux x64..." - + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ -c Release \ -o ./publish/launcher-linux-x64 \ @@ -395,13 +425,17 @@ jobs: -p:IncludeNativeLibrariesForSelfExtract=true \ -p:EnableCompressionInSingleFile=true \ -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 echo "Launcher AOT publish failed" exit 1 fi - + echo "Launcher published to: ./publish/launcher-linux-x64" ls -lh ./publish/launcher-linux-x64/ @@ -587,7 +621,7 @@ jobs: - name: Publish Launcher (AOT) run: | echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..." - + dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \ -c Release \ -o ./publish/launcher-macos-${{ matrix.arch }} \ @@ -598,13 +632,17 @@ jobs: -p:IncludeNativeLibrariesForSelfExtract=true \ -p:EnableCompressionInSingleFile=true \ -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 echo "Launcher AOT publish failed" exit 1 fi - + echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}" ls -lh ./publish/launcher-macos-${{ matrix.arch }}/ @@ -737,6 +775,10 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -816,6 +858,21 @@ jobs: shell: pwsh run: | $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 ` -Version $env:VERSION ` @@ -826,7 +883,14 @@ jobs: -Channel "stable" ` -S3Endpoint $env:S3_ENDPOINT ` -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 uses: actions/upload-artifact@v4 @@ -872,7 +936,7 @@ jobs: echo "Organizing artifacts..." mkdir -p 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 "Files ready for release:" 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. - ### 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-x86.json / files-windows-x86.json.sig / update-windows-x86.zip** - **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip** @@ -923,3 +995,42 @@ jobs: See commits for changes. 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 diff --git a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs index 6ce9387..75c0916 100644 --- a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Runtime.InteropServices; using System.Text.Json; @@ -17,6 +18,7 @@ namespace LanMountainDesktop.Services; public sealed class PlondsReleaseUpdateService : IDisposable { private const string DefaultApiBasePath = "/api/plonds/v1"; + private const int MaxTransientRetryAttempts = 3; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; @@ -71,6 +73,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable var normalizedCurrentVersion = NormalizeVersion(currentVersion); var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion); var endpoint = ResolveEndpoint(); + var latestVersionText = "-"; if (string.IsNullOrWhiteSpace(endpoint)) { @@ -78,7 +81,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable Success: false, IsUpdateAvailable: false, CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: "-", + LatestVersionText: latestVersionText, Release: null, PreferredAsset: null, ErrorMessage: "PLONDS endpoint is not configured.", @@ -89,8 +92,6 @@ public sealed class PlondsReleaseUpdateService : IDisposable { var apiBasePath = ResolveApiBasePath(); var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata"); - var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false); - var channelId = ResolveChannelId(includePrerelease); var platform = ResolvePlatform(); var latestUrl = BuildApiUrl( @@ -98,12 +99,14 @@ public sealed class PlondsReleaseUpdateService : IDisposable apiBasePath, $"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}"); - JsonElement latestNode; - try - { - latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false); - } - catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase)) + _ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false); + + var latestDescriptor = await GetLatestDescriptorAsync( + latestUrl, + allowNoUpdateResponse: true, + cancellationToken).ConfigureAwait(false); + + if (latestDescriptor is null) { return new UpdateCheckResult( Success: true, @@ -116,35 +119,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable ForceMode: isForce); } - var latestVersionText = ReadString(latestNode, "version") ?? "-"; - if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null) - { - 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; + latestVersionText = latestDescriptor.VersionText; + var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion; if (!isForce && !hasUpdate) { return new UpdateCheckResult( @@ -158,58 +134,67 @@ public sealed class PlondsReleaseUpdateService : IDisposable ForceMode: false); } - var distributionUrl = BuildApiUrl( + var distribution = await ResolveDistributionAsync( endpoint, apiBasePath, - $"distributions/{Uri.EscapeDataString(distributionId)}"); - var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false); + latestUrl, + latestDescriptor, + channelId, + platform, + cancellationToken).ConfigureAwait(false); - var assets = ResolveInstallerAssets(distributionNode); - 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); - } + latestVersionText = distribution.Latest.VersionText; - var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow; + var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow; var release = new GitHubReleaseInfo( - TagName: $"v{latestVersionText}", - Name: $"PLONDS Distribution {latestVersionText}", + TagName: $"v{distribution.Latest.VersionText}", + Name: $"PLONDS Distribution {distribution.Latest.VersionText}", IsPrerelease: includePrerelease, IsDraft: false, PublishedAt: publishedAt, - Assets: assets); + Assets: distribution.Assets); return new UpdateCheckResult( Success: true, IsUpdateAvailable: true, CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: latestVersionText, + LatestVersionText: distribution.Latest.VersionText, Release: release, - PreferredAsset: SelectPreferredInstallerAsset(assets), + PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets), ErrorMessage: null, ForceMode: isForce, - PlondsPayload: payload); + PlondsPayload: distribution.Payload); } catch (OperationCanceledException) { throw; } - catch (Exception ex) + catch (PlondsRequestException ex) { + AppLogger.Warn( + "PLONDS", + $"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}", + ex); + return new UpdateCheckResult( Success: false, IsUpdateAvailable: false, 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, PreferredAsset: null, ErrorMessage: $"PLONDS request failed: {ex.Message}", @@ -217,7 +202,125 @@ public sealed class PlondsReleaseUpdateService : IDisposable } } - private async Task GetJsonNodeAsync(string url, CancellationToken cancellationToken) + private async Task 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 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 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 GetJsonNodeAsync( + string url, + PlondsCheckStage stage, + CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, url); var token = ResolveToken(); @@ -226,22 +329,127 @@ public sealed class PlondsReleaseUpdateService : IDisposable request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); } - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + HttpResponseMessage response; + try { - 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); - var root = document.RootElement; - if (root.ValueKind == JsonValueKind.Object && - root.TryGetProperty("content", out var content)) + using (response) { - 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 ResolveInstallerAssets(JsonElement distributionNode) @@ -258,7 +466,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable continue; } - var name = ReadString(installerNode, "name"); + var name = ReadString(installerNode, "name") + ?? ReadString(installerNode, "fileName"); var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl"); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) { @@ -593,4 +802,91 @@ public sealed class PlondsReleaseUpdateService : IDisposable 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 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; } + } } diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 5148356..6e24d2b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl AppLogger.Warn( "UpdateSettings", $"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 diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 70800e9..e8479c9 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -71,6 +71,7 @@ public sealed class UpdateWorkflowService }; private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient); + private const int MaxPlondsOuterRetryAttempts = 3; public UpdateWorkflowService(ISettingsFacadeService settingsFacade) { @@ -251,7 +252,12 @@ public sealed class UpdateWorkflowService var payload = checkResult.PlondsPayload; 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(); @@ -264,7 +270,12 @@ public sealed class UpdateWorkflowService } 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 @@ -279,18 +290,31 @@ public sealed class UpdateWorkflowService payload.FileMapJson, payload.FileMapJsonUrl, fileMapPath, + "file map", + "filemap-download", cancellationToken); var fileMapSignature = await EnsurePlondsTextResourceAsync( payload.FileMapSignature, payload.FileMapSignatureUrl, signaturePath, + "file map signature", + "filemap-download", cancellationToken); - var downloadEntries = ParsePlondsDownloadEntries(fileMapJson); + IReadOnlyList 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) { - 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; @@ -310,46 +334,13 @@ public sealed class UpdateWorkflowService continue; } - var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex); - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - { - 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, + var objectInfo = await EnsurePlondsObjectAsync( + entry, + objectsDir, + downloadThreads, cancellationToken); - if (!downloadResult.Success) - { - 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)); + objectResults.Add(objectInfo); completedItems++; progress?.Report((double)completedItems / totalSteps); } @@ -390,8 +381,20 @@ public sealed class UpdateWorkflowService } catch (Exception ex) { - AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex); - return new UpdateDownloadResult(false, null, ex.Message); + var stage = ex is PlondsDownloadException plondsException + ? 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); } + private async Task DownloadFullInstallerAsync( + UpdateCheckResult checkResult, + IProgress? 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 HandlePlondsDeltaFailureAsync( + UpdateCheckResult checkResult, + string stage, + string errorMessage, + IProgress? 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) { var normalizedHash = objectHashHex.Trim().ToLowerInvariant(); @@ -431,6 +553,8 @@ public sealed class UpdateWorkflowService string? inlineContent, string? sourceUrl, string destinationPath, + string resourceName, + string stage, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(inlineContent)) @@ -441,20 +565,131 @@ public sealed class UpdateWorkflowService 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( - sourceUrl, - destinationPath, - cancellationToken: cancellationToken); - - if (!downloadResult.Success) + Exception? lastError = null; + for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++) { - 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 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 ?? ""}"; + 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 ParsePlondsDownloadEntries(string fileMapJson) @@ -628,6 +863,31 @@ public sealed class UpdateWorkflowService 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) { if (node.ValueKind == JsonValueKind.Object) @@ -742,6 +1002,17 @@ public sealed class UpdateWorkflowService string DownloadUrl, 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( string ComponentId, string RelativePath, @@ -876,53 +1147,11 @@ public sealed class UpdateWorkflowService return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); } - 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 (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, + return await DownloadFullInstallerAsync( + checkResult, 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; + cancellationToken, + forceRedownload: false); } public async Task RedownloadReleaseAsync( @@ -938,58 +1167,11 @@ public sealed class UpdateWorkflowService return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); } - 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 (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, + return await DownloadFullInstallerAsync( + checkResult, 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; + cancellationToken, + forceRedownload: true); } public async Task VerifyPendingUpdateAsync() diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 0104a82..08f05b7 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase return; } - if (result.PreferredAsset is null) + if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result)) { UpdateStatus = isForce ? 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))] 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."); return; @@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase UpdateDownloadResult downloadResult; // 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..."); downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress); - if (!downloadResult.Success) + if (!downloadResult.Success && result.PlondsPayload is null) { // Delta download failed, fall back to full installer AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer."); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/README.md b/PenguinLogisticsOnlineNetworkDistributionSystem/README.md index 78bf0b1..ec1ab7d 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/README.md +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/README.md @@ -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 PenguinLogisticsOnlineNetworkDistributionSystem/ @@ -22,72 +22,72 @@ PenguinLogisticsOnlineNetworkDistributionSystem/ distributions/ ``` -## Projects +## 项目说明 -- `Plonds.Shared` provides protocol constants and models. -- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration. -- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool. -- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3. +- `Plonds.Shared` 提供协议常量和数据模型。 +- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。 +- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。 +- `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 -Host App - -> checks updates, downloads objects, stages incoming payload -Launcher - -> verifies signature, applies file map, switches deployment, rolls back +宿主应用 + -> 检查更新、下载对象、暂存传入的负载 +启动器 + -> 验证签名、应用文件映射、切换部署、回滚 PLONDS.Api - -> read-only metadata projection for clients + -> 面向客户端的只读元数据投影 PLONDS.Tool - -> CI/release command surface + -> CI/发布命令界面 PLONDS.Core - -> hash/diff/object-repo/sign/publish implementation + -> 哈希/差异/对象仓库/签名/发布实现 PLONDS.Shared - -> protocol constants and DTOs + -> 协议常量和 DTO ``` -Rules for v1: +## v1 规则 -- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts. -- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience. -- Host keeps download responsibility. -- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility. +- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。 +- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。 +- 宿主应用保留下载职责。 +- 启动器保留应用、原子切换、快照和回滚职责。 -## Storage Layout +## 存储布局 -The first version keeps one fixed object root: +第一版本保持固定的对象根目录: ```text lanmountain/update/ - repo/sha256// - meta/channels///latest.json - meta/distributions/.json - installers///... + repo/sha256/<前缀>/<哈希> + meta/channels/<频道>/<平台>/latest.json + meta/distributions/<分发ID>.json + installers/<平台>/<版本>/... ``` -Planned but not enabled in v1: +已规划但 v1 中未启用: ```text -lanmountain/update/repo-compressed/// -lanmountain/update/patches/// +lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希> +lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希> ``` -## Public Endpoints +## 公共接口 -The API base path is `/api/plonds/v1`. +API 基础路径为 `/api/plonds/v1`。 -- `GET /healthz` -- `GET /api/plonds/v1/metadata` -- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` -- `GET /api/plonds/v1/distributions/{distributionId}` +- `GET /healthz` - 健康检查 +- `GET /api/plonds/v1/metadata` - 获取元数据目录 +- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本 +- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息 -## Local Run +## 本地运行 ```powershell dotnet run --project src/Plonds.Api ``` -By default the API reads metadata from `sample-data`. +默认情况下,API 从 `sample-data` 读取元数据。 diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs index 1f44668..aca01a8 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs @@ -13,4 +13,11 @@ public sealed record PlondsGenerateOptions( string? FileMapUrl = null, string? FileMapSignatureUrl = 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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs index e6e7939..55242a9 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs @@ -42,11 +42,16 @@ public sealed class PlondsGenerator Directory.CreateDirectory(metaDistributionRoot); Directory.CreateDirectory(metaChannelRoot); - var previousManifest = ScanDirectory(previousDirectory); + var previousManifest = options.IsFullPayloadRelease + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : ScanDirectory(previousDirectory); var currentManifest = ScanDirectory(currentDirectory); var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl); var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl); var publishedAt = DateTimeOffset.UtcNow; + var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion) + ? options.PreviousVersion + : options.BaselineVersion; var fileMap = new FileMapDocument( FormatVersion: "1.0", @@ -69,7 +74,14 @@ public sealed class PlondsGenerator Metadata: new Dictionary { ["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( @@ -83,7 +95,17 @@ public sealed class PlondsGenerator Components: fileMap.Components, InstallerMirrors: installerMirrors, Capabilities: ["file-object"], - Metadata: new Dictionary { ["protocol"] = "PLONDS" }); + Metadata: new Dictionary + { + ["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, @@ -225,6 +247,7 @@ public sealed class PlondsGenerator Platform: platform, Arch: ResolveArch(platform), Url: url, + Name: fileName, FileName: fileName, Sha256: ComputeSha256(destinationPath), Size: new FileInfo(destinationPath).Length)); @@ -345,6 +368,7 @@ public sealed class PlondsGenerator string Platform, string Arch, string? Url, + string? Name, string? FileName, string? Sha256, long Size); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs index 6992fcc..7ee4092 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs @@ -9,4 +9,11 @@ public sealed record PlondsPublishOptions( string Channel = "stable", string? BaselineRoot = 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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs index e931b30..909a576 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs @@ -82,7 +82,7 @@ public sealed class PlondsPublisher CurrentDirectory: currentAppDirectory, Platform: config.Platform, OutputRoot: options.OutputRoot, - PreviousVersion: previousVersion, + PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion, PreviousDirectory: previousDirectory, Channel: options.Channel, DistributionId: distributionId, @@ -90,7 +90,14 @@ public sealed class PlondsPublisher FileMapUrl: fileMapUrl, FileMapSignatureUrl: fileMapSignatureUrl, 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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index ccdbadf..9b4c51c 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -86,7 +86,14 @@ internal static class PlondsCli Channel: Get(options, "channel", "stable") ?? "stable", BaselineRoot: Get(options, "baseline-root"), 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) { diff --git a/scripts/Publish-Plonds.ps1 b/scripts/Publish-Plonds.ps1 index 8250dba..f0ca9c9 100644 --- a/scripts/Publish-Plonds.ps1 +++ b/scripts/Publish-Plonds.ps1 @@ -1,4 +1,4 @@ -param( +param( [Parameter(Mandatory = $true)] [string]$Version, @@ -24,11 +24,70 @@ [string]$S3Bucket = "", [Parameter(Mandatory = $false)] - [string]$S3Region = "" + [string]$S3Region = "", + + [Parameter(Mandatory = $false)] + [string]$IncrementalStrategy = "release-payload", + + [Parameter(Mandatory = $false)] + [string]$PublishIncrementalRelease = "true", + + [Parameter(Mandatory = $false)] + [string]$BaselineRef = "", + + [Parameter(Mandatory = $false)] + [string]$GitHubRepository = "", + + [Parameter(Mandatory = $false)] + [string]$GitHubTag = "", + + [Parameter(Mandatory = $false)] + [string]$MirrorInstallersToS3 = "false", + + [Parameter(Mandatory = $false)] + [string]$UploadMetaToS3 = "true" ) $ErrorActionPreference = "Stop" +function ConvertTo-Boolean { + param( + [Parameter(Mandatory = $true)] + [string]$Value, + + [Parameter(Mandatory = $false)] + [bool]$DefaultValue = $false + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $DefaultValue + } + + return $Value.Trim().ToLowerInvariant() -in @("1", "true", "yes", "y", "on") +} + +function Get-GitHubReleaseBaseUrl { + param( + [Parameter(Mandatory = $false)] + [string]$Repository, + + [Parameter(Mandatory = $false)] + [string]$Tag + ) + + if ([string]::IsNullOrWhiteSpace($Repository) -or [string]::IsNullOrWhiteSpace($Tag)) { + return $null + } + + $normalizedRepository = $Repository.Trim().Trim('/') + $normalizedTag = $Tag.Trim() + if ($normalizedTag.StartsWith("refs/tags/", [System.StringComparison]::OrdinalIgnoreCase)) { + $normalizedTag = $normalizedTag.Substring("refs/tags/".Length) + } + + return "https://github.com/$normalizedRepository/releases/download/$normalizedTag" +} + function Get-PlatformConfigurations { return @( @{ @@ -67,6 +126,19 @@ function Resolve-AppDirectory { return $fallback?.FullName } +function Clear-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + + if (Test-Path -LiteralPath $Path) { + Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + } + else { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } +} + function Invoke-AwsCommandIfPossible { param( [Parameter(Mandatory = $true)] @@ -77,24 +149,21 @@ function Invoke-AwsCommandIfPossible { ) if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { - return + return $null } $previousRequestChecksumCalculation = $env:AWS_REQUEST_CHECKSUM_CALCULATION $previousResponseChecksumValidation = $env:AWS_RESPONSE_CHECKSUM_VALIDATION - # Rainyun's S3-compatible endpoint rejects AWS CLI v2's default checksum headers - # during multipart uploads. Restrict checksum behavior to API-required cases only. $env:AWS_REQUEST_CHECKSUM_CALCULATION = "WHEN_REQUIRED" $env:AWS_RESPONSE_CHECKSUM_VALIDATION = "WHEN_REQUIRED" try { if ($IgnoreFailure) { - & aws @Arguments 2>$null - } - else { - & aws @Arguments + return (& aws @Arguments 2>$null) } + + return (& aws @Arguments) } finally { if ($null -eq $previousRequestChecksumCalculation) { @@ -111,10 +180,6 @@ function Invoke-AwsCommandIfPossible { $env:AWS_RESPONSE_CHECKSUM_VALIDATION = $previousResponseChecksumValidation } } - - if ($LASTEXITCODE -ne 0 -and -not $IgnoreFailure) { - throw "aws command failed: aws $($Arguments -join ' ')" - } } function Get-S3Key { @@ -149,53 +214,310 @@ function Get-RelativePath { return [System.IO.Path]::GetRelativePath($rootPath, $pathValue) } -function Get-RemoteS3Keys { +function Test-S3ObjectExists { + param([Parameter(Mandatory = $true)][string]$Key) + + if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { + return $false + } + + Invoke-AwsCommandIfPossible -Arguments @( + "--endpoint-url", $S3Endpoint, + "--region", $S3Region, + "s3api", "head-object", + "--bucket", $S3Bucket, + "--key", $Key.Trim('/').Replace('\', '/') + ) -IgnoreFailure | Out-Null + + return $LASTEXITCODE -eq 0 +} + +function Copy-S3ObjectToLocal { param( [Parameter(Mandatory = $true)] - [string]$Prefix + [string]$Key, + + [Parameter(Mandatory = $true)] + [string]$DestinationPath ) - $keys = [System.Collections.Generic.List[string]]::new() - $continuationToken = $null + New-Item -ItemType Directory -Path ([System.IO.Path]::GetDirectoryName($DestinationPath)) -Force | Out-Null - do { - $arguments = @( - "--endpoint-url", $S3Endpoint, - "--region", $S3Region, - "s3api", "list-objects-v2", - "--bucket", $S3Bucket, - "--prefix", $Prefix, - "--output", "json" - ) + Invoke-AwsCommandIfPossible -Arguments @( + "--endpoint-url", $S3Endpoint, + "--region", $S3Region, + "s3", "cp", + "s3://$S3Bucket/$($Key.Trim('/').Replace('\', '/'))", + $DestinationPath, + "--only-show-errors" + ) -IgnoreFailure | Out-Null - if (-not [string]::IsNullOrWhiteSpace($continuationToken)) { - $arguments += @("--continuation-token", $continuationToken) + return ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $DestinationPath)) +} + +function Get-S3JsonDocument { + param([Parameter(Mandatory = $true)][string]$Key) + + $tempPath = Join-Path $OutputDir ("_tmp_" + [System.Guid]::NewGuid().ToString("N") + ".json") + try { + if (-not (Copy-S3ObjectToLocal -Key $Key -DestinationPath $tempPath)) { + return $null } - $json = Invoke-AwsCommandIfPossible -Arguments $arguments + return Get-Content -LiteralPath $tempPath -Raw | ConvertFrom-Json -AsHashtable + } + finally { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + } +} - if ([string]::IsNullOrWhiteSpace($json)) { - break +function New-ZipFromDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + if (Test-Path -LiteralPath $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Force + } + + New-Item -ItemType Directory -Path ([System.IO.Path]::GetDirectoryName($DestinationPath)) -Force | Out-Null + [System.IO.Compression.ZipFile]::CreateFromDirectory($SourceDirectory, $DestinationPath, [System.IO.Compression.CompressionLevel]::Optimal, $false) +} + +function Expand-PayloadSnapshot { + param( + [Parameter(Mandatory = $true)] + [string]$Platform, + + [Parameter(Mandatory = $true)] + [string]$BaselineVersion, + + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + $payloadKey = "lanmountain/update/payloads/$Platform/$BaselineVersion/app-payload.zip" + if (-not (Test-S3ObjectExists -Key $payloadKey)) { + return $false + } + + $tempZip = Join-Path $OutputDir ("payload-" + $Platform + "-" + $BaselineVersion + ".zip") + try { + if (-not (Copy-S3ObjectToLocal -Key $payloadKey -DestinationPath $tempZip)) { + return $false } - $response = $json | ConvertFrom-Json - if ($response.Contents) { - foreach ($item in $response.Contents) { - if (-not [string]::IsNullOrWhiteSpace($item.Key)) { - $keys.Add($item.Key) - } + Clear-Directory -Path $DestinationPath + Expand-Archive -LiteralPath $tempZip -DestinationPath $DestinationPath -Force + return $true + } + finally { + Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue + } +} + +function Restore-LegacyBaseline { + param( + [Parameter(Mandatory = $true)] + [string]$Platform, + + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + + [Parameter(Mandatory = $true)] + [string]$VersionFilePath + ) + + Clear-Directory -Path $DestinationPath + Remove-Item -LiteralPath $VersionFilePath -Force -ErrorAction SilentlyContinue + + if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { + return + } + + Invoke-AwsCommandIfPossible -Arguments @( + "--endpoint-url", $S3Endpoint, + "--region", $S3Region, + "s3", "sync", + "s3://$S3Bucket/lanmountain/update/baselines/$Platform/current/", + $DestinationPath, + "--only-show-errors" + ) -IgnoreFailure | Out-Null + + Copy-S3ObjectToLocal -Key "lanmountain/update/baselines/$Platform/version.txt" -DestinationPath $VersionFilePath | Out-Null +} + +function ConvertTo-NormalizedVersion { + param([Parameter(Mandatory = $false)][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $trimmed = $Value.Trim() + if ($trimmed.StartsWith("refs/tags/", [System.StringComparison]::OrdinalIgnoreCase)) { + $trimmed = $trimmed.Substring("refs/tags/".Length) + } + + if ($trimmed.StartsWith("v", [System.StringComparison]::OrdinalIgnoreCase)) { + $trimmed = $trimmed.Substring(1) + } + + if ($trimmed -match '^\d+(\.\d+){1,3}$') { + return $trimmed + } + + return $null +} + +function Resolve-GitTagFromRef { + param([Parameter(Mandatory = $true)][string]$GitRef) + + $tag = (& git describe --tags --match "v*" --abbrev=0 $GitRef 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($tag)) { + return $null + } + + return $tag.Trim() +} + +function Get-LatestChannelPointer { + param([Parameter(Mandatory = $true)][string]$Platform) + + return Get-S3JsonDocument -Key "lanmountain/update/meta/channels/$Channel/$Platform/latest.json" +} + +function Get-CommitRangeInfo { + param( + [Parameter(Mandatory = $true)] + [string]$RangeStart, + + [Parameter(Mandatory = $true)] + [string]$RangeEnd + ) + + $files = (& git diff --name-only "$RangeStart..$RangeEnd" 2>$null) + if ($LASTEXITCODE -ne 0) { + return @{ + Start = $RangeStart + End = $RangeEnd + ChangeCount = 0 + HasPotentialPayloadImpact = $true + RequiresComponentExpansion = $true + SamplePaths = "" + } + } + + $changes = @($files | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $ignoredPrefixes = @(".github/", ".trae/", "docs/", "PenguinLogisticsOnlineNetworkDistributionSystem/") + $ignoredExtensions = @(".md", ".txt") + $expansionPrefixes = @( + "LanMountainDesktop/", + "LanMountainDesktop.Launcher/", + "LanMountainDesktop.Appearance/", + "LanMountainDesktop.PluginSdk/", + "LanMountainDesktop.Settings.Core/", + "LanMountainDesktop.Shared.Contracts/", + "LanMountainDesktop.Tests/", + "scripts/" + ) + $expansionExtensions = @(".csproj", ".props", ".targets", ".sln", ".slnx", ".json", ".axaml", ".resx") + + $impactfulChanges = [System.Collections.Generic.List[string]]::new() + $requiresExpansion = $false + + foreach ($change in $changes) { + $normalized = $change.Replace('\', '/') + $extension = [System.IO.Path]::GetExtension($normalized) + + $isIgnored = $false + foreach ($ignoredPrefix in $ignoredPrefixes) { + if ($normalized.StartsWith($ignoredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $isIgnored = $true + break + } + } + if (-not $isIgnored -and $ignoredExtensions -contains $extension.ToLowerInvariant()) { + $isIgnored = $true + } + + if ($isIgnored) { + continue + } + + $impactfulChanges.Add($normalized) + + foreach ($expansionPrefix in $expansionPrefixes) { + if ($normalized.StartsWith($expansionPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $requiresExpansion = $true + break } } - if ($response.IsTruncated -and -not [string]::IsNullOrWhiteSpace($response.NextContinuationToken)) { - $continuationToken = $response.NextContinuationToken + if ($requiresExpansion -or $expansionExtensions -contains $extension.ToLowerInvariant()) { + $requiresExpansion = $true } - else { - $continuationToken = $null - } - } while (-not [string]::IsNullOrWhiteSpace($continuationToken)) + } - return $keys + return @{ + Start = $RangeStart + End = $RangeEnd + ChangeCount = $changes.Count + HasPotentialPayloadImpact = ($impactfulChanges.Count -gt 0) + RequiresComponentExpansion = $requiresExpansion + SamplePaths = (($impactfulChanges | Select-Object -First 10) -join "; ") + } +} + +function Update-JsonMetadata { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [hashtable]$Metadata + ) + + $document = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + if (-not $document.ContainsKey("metadata") -or $null -eq $document.metadata) { + $document.metadata = @{} + } + + foreach ($key in $Metadata.Keys) { + if ($null -ne $Metadata[$key] -and -not [string]::IsNullOrWhiteSpace([string]$Metadata[$key])) { + $document.metadata[$key] = [string]$Metadata[$key] + } + } + + $document | ConvertTo-Json -Depth 64 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM +} + +function Get-FileMapChangeSummary { + param([Parameter(Mandatory = $true)][string]$Path) + + $document = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + $summary = @{ + Add = 0 + Replace = 0 + Reuse = 0 + Delete = 0 + } + + foreach ($component in @($document.components)) { + foreach ($file in @($component.files)) { + $operation = [string]$file.op + if ($summary.ContainsKey($operation.Substring(0, 1).ToUpperInvariant() + $operation.Substring(1))) { + $summary[$operation.Substring(0, 1).ToUpperInvariant() + $operation.Substring(1)]++ + } + } + } + + return $summary } function Upload-DirectoryToS3 { @@ -207,18 +529,18 @@ function Upload-DirectoryToS3 { [string]$RemotePrefix, [Parameter(Mandatory = $false)] - [switch]$DeleteExtraRemoteObjects + [switch]$SkipExisting ) if (-not (Test-Path -LiteralPath $LocalRoot)) { - throw "Local upload root not found: $LocalRoot" + Write-Host "Skipping missing upload root: $LocalRoot" + return } $files = Get-ChildItem -LiteralPath $LocalRoot -Recurse -File | Sort-Object FullName - $uploadedKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - if ($files.Count -eq 0) { Write-Host "No files found under $LocalRoot; skipping upload." + return } $index = 0 @@ -226,7 +548,13 @@ function Upload-DirectoryToS3 { $index++ $relativePath = Get-RelativePath -Root $LocalRoot -Path $file.FullName $key = Get-S3Key -Prefix $RemotePrefix -RelativePath $relativePath - $null = $uploadedKeys.Add($key) + + if ($SkipExisting -and (Test-S3ObjectExists -Key $key)) { + if ($index -eq 1 -or $index % 25 -eq 0 -or $index -eq $files.Count) { + Write-Host "Skipping existing $index/$($files.Count): $key" + } + continue + } if ($index -eq 1 -or $index % 25 -eq 0 -or $index -eq $files.Count) { Write-Host "Uploading $index/$($files.Count): $key" @@ -239,47 +567,126 @@ function Upload-DirectoryToS3 { "--bucket", $S3Bucket, "--key", $key, "--body", $file.FullName - ) - } + ) | Out-Null - if ($DeleteExtraRemoteObjects) { - $remoteKeys = Get-RemoteS3Keys -Prefix $RemotePrefix.Trim('/').Replace('\', '/') - foreach ($remoteKey in $remoteKeys) { - if (-not $uploadedKeys.Contains($remoteKey)) { - Write-Host "Deleting stale remote object: $remoteKey" - Invoke-AwsCommandIfPossible -Arguments @( - "--endpoint-url", $S3Endpoint, - "--region", $S3Region, - "s3api", "delete-object", - "--bucket", $S3Bucket, - "--key", $remoteKey - ) - } + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload $key" } } } -function Upload-FileToS3 { +function Upload-InstallerDirectoryToS3 { param( [Parameter(Mandatory = $true)] - [string]$LocalPath, + [string]$LocalRoot, [Parameter(Mandatory = $true)] - [string]$RemoteKey + [string]$RemotePrefix ) - if (-not (Test-Path -LiteralPath $LocalPath)) { - throw "Local upload file not found: $LocalPath" + if (-not (Test-Path -LiteralPath $LocalRoot)) { + Write-Host "Skipping missing installer upload root: $LocalRoot" + return } - Invoke-AwsCommandIfPossible -Arguments @( - "--endpoint-url", $S3Endpoint, - "--region", $S3Region, - "s3api", "put-object", - "--bucket", $S3Bucket, - "--key", $RemoteKey.Trim('/').Replace('\', '/'), - "--body", $LocalPath - ) + $files = Get-ChildItem -LiteralPath $LocalRoot -Recurse -File | Sort-Object FullName + if ($files.Count -eq 0) { + Write-Host "No installer files found under $LocalRoot; skipping installer upload." + return + } + + $tempDir = Join-Path $OutputDir ("_aws-installer-config-" + [System.Guid]::NewGuid().ToString("N")) + $tempConfigPath = Join-Path $tempDir "config" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + @" +[default] +s3 = + preferred_transfer_client = classic + addressing_style = path + max_concurrent_requests = 4 + max_queue_size = 32 + multipart_threshold = 64MB + multipart_chunksize = 32MB + payload_signing_enabled = false +"@ | Set-Content -LiteralPath $tempConfigPath -Encoding ascii + + $previousConfigFile = $env:AWS_CONFIG_FILE + $previousRetryMode = $env:AWS_RETRY_MODE + $previousMaxAttempts = $env:AWS_MAX_ATTEMPTS + $env:AWS_CONFIG_FILE = $tempConfigPath + $env:AWS_RETRY_MODE = "adaptive" + $env:AWS_MAX_ATTEMPTS = "6" + + try { + $index = 0 + foreach ($file in $files) { + $index++ + $relativePath = Get-RelativePath -Root $LocalRoot -Path $file.FullName + $key = Get-S3Key -Prefix $RemotePrefix -RelativePath $relativePath + + if (Test-S3ObjectExists -Key $key) { + if ($index -eq 1 -or $index % 10 -eq 0 -or $index -eq $files.Count) { + Write-Host "Skipping existing installer $index/$($files.Count): $key" + } + continue + } + + Write-Host "Uploading installer $index/$($files.Count): $key" + Invoke-AwsCommandIfPossible -Arguments @( + "--cli-connect-timeout", "60", + "--cli-read-timeout", "0", + "--endpoint-url", $S3Endpoint, + "--region", $S3Region, + "s3", "cp", + $file.FullName, + "s3://$S3Bucket/$key", + "--only-show-errors", + "--no-progress" + ) -IgnoreFailure | Out-Null + + if ($LASTEXITCODE -eq 0) { + continue + } + + Write-Warning "Multipart installer upload failed for $key, falling back to put-object." + Invoke-AwsCommandIfPossible -Arguments @( + "--endpoint-url", $S3Endpoint, + "--region", $S3Region, + "s3api", "put-object", + "--bucket", $S3Bucket, + "--key", $key, + "--body", $file.FullName + ) | Out-Null + + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload installer mirror: $key" + } + } + } + finally { + if ($null -eq $previousConfigFile) { + Remove-Item Env:AWS_CONFIG_FILE -ErrorAction SilentlyContinue + } + else { + $env:AWS_CONFIG_FILE = $previousConfigFile + } + + if ($null -eq $previousRetryMode) { + Remove-Item Env:AWS_RETRY_MODE -ErrorAction SilentlyContinue + } + else { + $env:AWS_RETRY_MODE = $previousRetryMode + } + + if ($null -eq $previousMaxAttempts) { + Remove-Item Env:AWS_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + else { + $env:AWS_MAX_ATTEMPTS = $previousMaxAttempts + } + + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } } if (-not (Test-Path -LiteralPath $PrivateKeyPath)) { @@ -295,39 +702,24 @@ $supportedPlatforms = Get-PlatformConfigurations $publishedRoot = Join-Path $OutputDir "published" $releaseAssetsRoot = Join-Path $OutputDir "release-assets" $baselineRoot = Join-Path $OutputDir "_baselines" +$legacyRoot = Join-Path $OutputDir "legacy" +$publishIncremental = ConvertTo-Boolean -Value $PublishIncrementalRelease -DefaultValue $true +$isFullPayloadRelease = -not $publishIncremental +$mirrorInstallers = ConvertTo-Boolean -Value $MirrorInstallersToS3 -DefaultValue $false +$uploadMetaToS3 = ConvertTo-Boolean -Value $UploadMetaToS3 -DefaultValue $true +$gitHubReleaseBaseUrl = Get-GitHubReleaseBaseUrl -Repository $GitHubRepository -Tag $GitHubTag +$sourceCommit = (& git rev-parse HEAD 2>$null) +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceCommit)) { + $sourceCommit = "" +} +else { + $sourceCommit = $sourceCommit.Trim() +} New-Item -ItemType Directory -Path $publishedRoot -Force | Out-Null New-Item -ItemType Directory -Path $releaseAssetsRoot -Force | Out-Null New-Item -ItemType Directory -Path $baselineRoot -Force | Out-Null - -foreach ($config in $supportedPlatforms) { - $platform = $config.Platform - $platformBaselineRoot = Join-Path $baselineRoot $platform - $baselineCurrentDir = Join-Path $platformBaselineRoot "current" - $baselineVersionPath = Join-Path $platformBaselineRoot "version.txt" - - New-Item -ItemType Directory -Path $baselineCurrentDir -Force | Out-Null - - if (-not [string]::IsNullOrWhiteSpace($S3Endpoint) -and -not [string]::IsNullOrWhiteSpace($S3Bucket)) { - Invoke-AwsCommandIfPossible -Arguments @( - "--endpoint-url", $S3Endpoint, - "--region", $S3Region, - "s3", "sync", - "s3://$S3Bucket/lanmountain/update/baselines/$platform/current/", - $baselineCurrentDir, - "--only-show-errors" - ) -IgnoreFailure - - Invoke-AwsCommandIfPossible -Arguments @( - "--endpoint-url", $S3Endpoint, - "--region", $S3Region, - "s3", "cp", - "s3://$S3Bucket/lanmountain/update/baselines/$platform/version.txt", - $baselineVersionPath, - "--only-show-errors" - ) -IgnoreFailure - } -} +New-Item -ItemType Directory -Path $legacyRoot -Force | Out-Null $repoBaseUrl = if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { $null @@ -336,31 +728,114 @@ else { "$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/repo/sha256" } -$installerBaseUrl = if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { +$installerBaseUrl = if (-not [string]::IsNullOrWhiteSpace($gitHubReleaseBaseUrl)) { + $gitHubReleaseBaseUrl +} +elseif ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) { $null } else { "$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/installers" } +$installerMirrorMode = if (-not [string]::IsNullOrWhiteSpace($gitHubReleaseBaseUrl)) { + "github-release" +} +elseif ($mirrorInstallers -and -not [string]::IsNullOrWhiteSpace($installerBaseUrl)) { + "s3" +} +else { + "none" +} -$legacySnapshots = @{} +$resolvedBaselineVersionOverride = ConvertTo-NormalizedVersion -Value $BaselineRef +$resolvedBaselineRefOverride = if ([string]::IsNullOrWhiteSpace($BaselineRef)) { + $null +} +elseif (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersionOverride)) { + "v$resolvedBaselineVersionOverride" +} +else { + $BaselineRef.Trim() +} + +$platformStates = @{} foreach ($config in $supportedPlatforms) { $platform = $config.Platform $platformBaselineRoot = Join-Path $baselineRoot $platform $baselineCurrentDir = Join-Path $platformBaselineRoot "current" $baselineVersionPath = Join-Path $platformBaselineRoot "version.txt" $snapshotRoot = Join-Path $platformBaselineRoot "previous-snapshot" + $emptyRoot = Join-Path $platformBaselineRoot "empty" - if (Test-Path -LiteralPath $snapshotRoot) { - Remove-Item -LiteralPath $snapshotRoot -Recurse -Force + New-Item -ItemType Directory -Path $platformBaselineRoot -Force | Out-Null + Clear-Directory -Path $baselineCurrentDir + Clear-Directory -Path $snapshotRoot + Clear-Directory -Path $emptyRoot + + $latestPointer = $null + $resolvedBaselineVersion = $resolvedBaselineVersionOverride + $resolvedBaselineRef = $resolvedBaselineRefOverride + + if (-not $resolvedBaselineVersion) { + if (-not [string]::IsNullOrWhiteSpace($BaselineRef)) { + $resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { $BaselineRef.Trim() } + $tag = Resolve-GitTagFromRef -GitRef $BaselineRef.Trim() + if ($tag) { + $resolvedBaselineVersion = ConvertTo-NormalizedVersion -Value $tag + if (-not $resolvedBaselineRef) { + $resolvedBaselineRef = $tag + } + } + } + else { + $latestPointer = Get-LatestChannelPointer -Platform $platform + if ($latestPointer) { + $resolvedBaselineVersion = [string]$latestPointer.version + $resolvedBaselineRef = if ([string]::IsNullOrWhiteSpace([string]$latestPointer.version)) { $null } else { "v$($latestPointer.version)" } + } + } } - New-Item -ItemType Directory -Path $snapshotRoot -Force | Out-Null - $previousVersion = if (Test-Path -LiteralPath $baselineVersionPath) { - (Get-Content -LiteralPath $baselineVersionPath -Raw).Trim() + $baselineSource = "none" + if ($isFullPayloadRelease) { + "0.0.0" | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii + $baselineSource = "empty" } else { - "0.0.0" + $restored = $false + if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { + $restored = Expand-PayloadSnapshot -Platform $platform -BaselineVersion $resolvedBaselineVersion -DestinationPath $baselineCurrentDir + if ($restored) { + $baselineSource = "payload" + $resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { "v$resolvedBaselineVersion" } + } + } + + if (-not $restored) { + Restore-LegacyBaseline -Platform $platform -DestinationPath $baselineCurrentDir -VersionFilePath $baselineVersionPath + $legacyVersion = if (Test-Path -LiteralPath $baselineVersionPath) { + (Get-Content -LiteralPath $baselineVersionPath -Raw).Trim() + } + else { + "" + } + + if (-not [string]::IsNullOrWhiteSpace($legacyVersion)) { + $resolvedBaselineVersion = $legacyVersion + $resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { "v$legacyVersion" } + $baselineSource = "legacy-baseline" + } + else { + "0.0.0" | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii + $resolvedBaselineVersion = "0.0.0" + $baselineSource = "empty" + } + } + + if (-not (Test-Path -LiteralPath $baselineVersionPath)) { + $versionToPersist = if ([string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { "0.0.0" } else { $resolvedBaselineVersion } + $versionToPersist | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii + } } $baselineItems = @(Get-ChildItem -LiteralPath $baselineCurrentDir -Force -ErrorAction SilentlyContinue) @@ -368,17 +843,48 @@ foreach ($config in $supportedPlatforms) { foreach ($baselineItem in $baselineItems) { Copy-Item -LiteralPath $baselineItem.FullName -Destination $snapshotRoot -Recurse -Force } - $snapshotDir = $snapshotRoot + $legacyPreviousDir = $snapshotRoot } else { - $snapshotDir = Join-Path $platformBaselineRoot "empty" - New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null + $legacyPreviousDir = $emptyRoot } - $legacySnapshots[$platform] = @{ - PreviousVersion = $previousVersion - PreviousDir = $snapshotDir + $commitInfo = @{ + Start = $null + End = $sourceCommit + ChangeCount = 0 + HasPotentialPayloadImpact = $true + RequiresComponentExpansion = $true + SamplePaths = "" } + + if ($IncrementalStrategy -eq "commit-range") { + $rangeStart = if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineRef)) { + $resolvedBaselineRef + } + elseif (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { + "v$resolvedBaselineVersion" + } + else { + $null + } + + if (-not [string]::IsNullOrWhiteSpace($rangeStart) -and -not [string]::IsNullOrWhiteSpace($sourceCommit)) { + $commitInfo = Get-CommitRangeInfo -RangeStart $rangeStart -RangeEnd $sourceCommit + } + } + + $platformStates[$platform] = @{ + Platform = $platform + ArtifactName = $config.ArtifactName + BaselineVersion = if ([string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { "0.0.0" } else { $resolvedBaselineVersion } + BaselineRef = $resolvedBaselineRef + BaselineSource = $baselineSource + LegacyPreviousDir = $legacyPreviousDir + CommitInfo = $commitInfo + } + + Write-Host "Prepared baseline for $platform => version=$($platformStates[$platform].BaselineVersion), source=$baselineSource, strategy=$IncrementalStrategy" } $publishArguments = @( @@ -392,16 +898,31 @@ $publishArguments = @( "--output-dir", $publishedRoot, "--private-key", $PrivateKeyPath, "--baseline-root", $baselineRoot, - "--channel", $Channel + "--channel", $Channel, + "--incremental-strategy", $IncrementalStrategy, + "--is-full-payload-release", $isFullPayloadRelease.ToString().ToLowerInvariant() ) if (-not [string]::IsNullOrWhiteSpace($repoBaseUrl)) { $publishArguments += @("--repo-base-url", $repoBaseUrl) } + if (-not [string]::IsNullOrWhiteSpace($installerBaseUrl)) { $publishArguments += @("--installer-base-url", $installerBaseUrl) } +if (-not [string]::IsNullOrWhiteSpace($sourceCommit)) { + $publishArguments += @("--source-commit", $sourceCommit) +} + +if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersionOverride)) { + $publishArguments += @("--baseline-version", $resolvedBaselineVersionOverride) +} + +if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineRefOverride)) { + $publishArguments += @("--baseline-ref", $resolvedBaselineRefOverride) +} + & dotnet @publishArguments if ($LASTEXITCODE -ne 0) { throw "PLONDS publish command failed." @@ -409,6 +930,7 @@ if ($LASTEXITCODE -ne 0) { foreach ($config in $supportedPlatforms) { $platform = $config.Platform + $state = $platformStates[$platform] $artifactRoot = Join-Path $AppArtifactsRoot $config.ArtifactName if (-not (Test-Path -LiteralPath $artifactRoot)) { throw "App payload artifact root not found for ${platform}: $artifactRoot" @@ -422,15 +944,56 @@ foreach ($config in $supportedPlatforms) { $distributionId = "plonds-$Version-$platform" $manifestPath = Join-Path $publishedRoot "manifests/$distributionId/plonds-filemap.json" $manifestSignaturePath = "$manifestPath.sig" + $distributionPath = Join-Path $publishedRoot "meta/distributions/$distributionId.json" + $latestPath = Join-Path $publishedRoot "meta/channels/$Channel/$platform/latest.json" + $payloadSnapshotPath = Join-Path $publishedRoot "payloads/$platform/$Version/app-payload.zip" + New-ZipFromDirectory -SourceDirectory $currentAppDir -DestinationPath $payloadSnapshotPath - $legacyOutputDir = Join-Path $OutputDir "legacy/$platform" + $changeSummary = Get-FileMapChangeSummary -Path $manifestPath + $changeCount = $changeSummary.Add + $changeSummary.Replace + $changeSummary.Delete + $commitVerificationAdjusted = $false + if ($IncrementalStrategy -eq "commit-range" -and -not $state.CommitInfo.HasPotentialPayloadImpact -and $changeCount -gt 0) { + $commitVerificationAdjusted = $true + Write-Warning "Commit range for $platform predicted no payload impact, but payload diff found $changeCount changes. Keeping payload diff as source of truth." + } + + $metadata = @{ + baselineVersion = $state.BaselineVersion + baselineRef = $state.BaselineRef + baselineSource = $state.BaselineSource + sourceCommit = $sourceCommit + incrementalStrategy = $IncrementalStrategy + isFullPayloadRelease = $isFullPayloadRelease.ToString().ToLowerInvariant() + commitRangeStart = $state.CommitInfo.Start + commitRangeEnd = $state.CommitInfo.End + commitChangeCount = [string]$state.CommitInfo.ChangeCount + commitHasPotentialPayloadImpact = [string]$state.CommitInfo.HasPotentialPayloadImpact + commitRequiresComponentExpansion = [string]$state.CommitInfo.RequiresComponentExpansion + commitVerificationAdjusted = [string]$commitVerificationAdjusted + commitSamplePaths = $state.CommitInfo.SamplePaths + payloadSnapshotPath = "lanmountain/update/payloads/$platform/$Version/app-payload.zip" + installerMirrorMode = $installerMirrorMode + installerMirrorBaseUrl = $installerBaseUrl + } + + Update-JsonMetadata -Path $manifestPath -Metadata $metadata + Update-JsonMetadata -Path $distributionPath -Metadata $metadata + + & (Join-Path $PSScriptRoot "Sign-FileMap.ps1") ` + -FilesJsonPath $manifestPath ` + -PrivateKeyPath $PrivateKeyPath ` + -OutputPath $manifestSignaturePath + if ($LASTEXITCODE -ne 0) { + throw "Failed to re-sign PLONDS manifest for $platform" + } + + $legacyOutputDir = Join-Path $legacyRoot $platform New-Item -ItemType Directory -Path $legacyOutputDir -Force | Out-Null - $legacyState = $legacySnapshots[$platform] & (Join-Path $PSScriptRoot "Generate-DeltaPackage.ps1") ` - -PreviousVersion $legacyState.PreviousVersion ` + -PreviousVersion $state.BaselineVersion ` -CurrentVersion $Version ` - -PreviousDir $legacyState.PreviousDir ` + -PreviousDir $state.LegacyPreviousDir ` -CurrentDir $currentAppDir ` -OutputDir $legacyOutputDir if ($LASTEXITCODE -ne 0) { @@ -449,8 +1012,9 @@ foreach ($config in $supportedPlatforms) { Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json") -Force Copy-Item -LiteralPath $manifestSignaturePath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json.sig") -Force - Copy-Item -LiteralPath (Join-Path $publishedRoot "meta/distributions/$distributionId.json") -Destination (Join-Path $releaseAssetsRoot "plonds-distribution-$platform.json") -Force - Copy-Item -LiteralPath (Join-Path $publishedRoot "meta/channels/$Channel/$platform/latest.json") -Destination (Join-Path $releaseAssetsRoot "plonds-latest-$platform.json") -Force + Copy-Item -LiteralPath $distributionPath -Destination (Join-Path $releaseAssetsRoot "plonds-distribution-$platform.json") -Force + Copy-Item -LiteralPath $latestPath -Destination (Join-Path $releaseAssetsRoot "plonds-latest-$platform.json") -Force + Copy-Item -LiteralPath $payloadSnapshotPath -Destination (Join-Path $releaseAssetsRoot "plonds-payload-$platform.zip") -Force Copy-Item -LiteralPath $legacyManifestPath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json") -Force Copy-Item -LiteralPath $legacySignaturePath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json.sig") -Force @@ -458,22 +1022,20 @@ foreach ($config in $supportedPlatforms) { } if (-not [string]::IsNullOrWhiteSpace($S3Endpoint) -and -not [string]::IsNullOrWhiteSpace($S3Bucket)) { - Upload-DirectoryToS3 -LocalRoot $publishedRoot -RemotePrefix "lanmountain/update" - - foreach ($config in $supportedPlatforms) { - $platform = $config.Platform - $platformBaselineRoot = Join-Path $baselineRoot $platform - $baselineCurrentDir = Join-Path $platformBaselineRoot "current" - $baselineVersionPath = Join-Path $platformBaselineRoot "version.txt" - - Upload-DirectoryToS3 ` - -LocalRoot $baselineCurrentDir ` - -RemotePrefix "lanmountain/update/baselines/$platform/current" ` - -DeleteExtraRemoteObjects - - Upload-FileToS3 ` - -LocalPath $baselineVersionPath ` - -RemoteKey "lanmountain/update/baselines/$platform/version.txt" + Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "payloads") -RemotePrefix "lanmountain/update/payloads" -SkipExisting + Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "repo") -RemotePrefix "lanmountain/update/repo" -SkipExisting + if ($mirrorInstallers) { + Upload-InstallerDirectoryToS3 -LocalRoot (Join-Path $publishedRoot "installers") -RemotePrefix "lanmountain/update/installers" + } + else { + Write-Host "Skipping blocking S3 installer mirror upload. Installer mirrors will resolve via $installerMirrorMode." + } + Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "manifests") -RemotePrefix "lanmountain/update/manifests" + if ($uploadMetaToS3) { + Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "meta") -RemotePrefix "lanmountain/update/meta" + } + else { + Write-Host "Deferring S3 meta upload until after GitHub Release is published." } }