From 8a75bc818ab28d24892d3b96b941df895ff4ff51 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 21 Apr 2026 19:26:59 +0800 Subject: [PATCH] Rebuild release pipeline around PLONDS and DDSS --- .github/workflows/ddss-publish.yml | 166 ++++ .github/workflows/plonds-build.yml | 235 +++++ .github/workflows/release.yml | 647 ++++--------- .../Services/UpdateEngineService.cs | 50 + .../Services/GitHubReleaseUpdateService.cs | 198 +++- .../Services/PlondsReleaseUpdateService.cs | 884 +----------------- .../Services/UpdateWorkflowService.cs | 173 +++- .../Publishing/DdssBuildOptions.cs | 9 + .../Publishing/DdssManifestBuilder.cs | 68 ++ .../Publishing/PayloadUtilities.cs | 235 +++++ .../Publishing/PlondsDeltaBuildOptions.cs | 14 + .../Publishing/PlondsDeltaBuildResult.cs | 13 + .../Publishing/PlondsDeltaBuilder.cs | 228 +++++ .../Publishing/PlondsReleaseIndexBuilder.cs | 57 ++ .../Publishing/PlondsReleaseIndexOptions.cs | 9 + .../Plonds.Shared/Models/DdssAssetEntry.cs | 8 + .../src/Plonds.Shared/Models/DdssManifest.cs | 7 + .../Plonds.Shared/Models/DdssMirrorEntry.cs | 5 + .../Models/PlondsReleaseManifest.cs | 9 + .../Models/PlondsReleasePlatformEntry.cs | 14 + .../src/Plonds.Tool/Program.cs | 76 +- 21 files changed, 1730 insertions(+), 1375 deletions(-) create mode 100644 .github/workflows/ddss-publish.yml create mode 100644 .github/workflows/plonds-build.yml create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssBuildOptions.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssManifestBuilder.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PayloadUtilities.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssAssetEntry.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssManifest.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssMirrorEntry.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml new file mode 100644 index 0000000..718407a --- /dev/null +++ b/.github/workflows/ddss-publish.yml @@ -0,0 +1,166 @@ +name: DDSS + +on: + workflow_run: + workflows: + - PLONDS + types: + - completed + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + type: string + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + publish: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Resolve release tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + RAW_TAG="${{ github.event.inputs.tag }}" + if [[ "$RAW_TAG" == v* ]]; then + TAG="$RAW_TAG" + else + TAG="v$RAW_TAG" + fi + else + gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata + TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)" + fi + + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: preview + + - name: Prepare signing key + env: + UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} + PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} + PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + KEY="${PLONDS_SIGNING_KEY:-}" + if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi + if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi + if [[ -z "$KEY" ]]; then + echo "No signing key is configured." + exit 1 + fi + printf '%s' "$KEY" > update-private-key.pem + echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV" + + - name: Build PLONDS tool + run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + mkdir -p release-assets + gh release download "$RELEASE_TAG" -D release-assets + find release-assets -maxdepth 1 -type f | sort + + - name: Upload release assets to Rainyun S3 + env: + 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 }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + aws --version + for file in release-assets/*; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then + continue + fi + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)" + if [[ "$existing_sha" == "$sha256" ]]; then + echo "Skip existing asset: $name" + continue + fi + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done + + - name: Build DDSS manifest + shell: bash + run: | + set -euo pipefail + mkdir -p ddss-output + dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \ + build-ddss \ + --release-tag "$RELEASE_TAG" \ + --assets-dir release-assets \ + --output-dir ddss-output \ + --private-key "$UPDATE_PRIVATE_KEY_PATH" \ + --repository "${{ github.repository }}" \ + --s3-base-url "$S3_BASE_URL" + + - name: Upload DDSS manifest to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber + + - name: Upload DDSS manifest to Rainyun S3 + env: + 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 }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do + name="$(basename "$file")" + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml new file mode 100644 index 0000000..3f53ade --- /dev/null +++ b/.github/workflows/plonds-build.yml @@ -0,0 +1,235 @@ +name: PLONDS + +on: + release: + types: + - published + - prereleased + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + type: string + baseline_tag: + description: 'Optional baseline tag' + required: false + type: string + channel: + description: 'Update channel' + required: false + type: choice + default: stable + options: + - stable + - preview + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Resolve release context + shell: bash + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + TAG="${{ github.event.release.tag_name }}" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + CHANNEL="preview" + else + CHANNEL="stable" + fi + BASELINE_TAG="" + else + RAW_TAG="${{ github.event.inputs.tag }}" + if [[ "${RAW_TAG}" == v* ]]; then + TAG="${RAW_TAG}" + else + TAG="v${RAW_TAG}" + fi + CHANNEL="${{ github.event.inputs.channel }}" + BASELINE_TAG="${{ github.event.inputs.baseline_tag }}" + fi + + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-quality: preview + + - name: Prepare signing key + env: + UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} + PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} + PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + KEY="${PLONDS_SIGNING_KEY:-}" + if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi + if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi + if [[ -z "$KEY" ]]; then + echo "No signing key is configured." + exit 1 + fi + printf '%s' "$KEY" > update-private-key.pem + echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV" + + - name: Build PLONDS tool + run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release + + - name: Resolve baseline plan + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $repo = '${{ github.repository }}' + $tag = $env:RELEASE_TAG + $baselineInput = $env:BASELINE_TAG_INPUT + $currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json + $allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json + $platforms = @('windows-x64', 'windows-x86', 'linux-x64') + + $entries = foreach ($platform in $platforms) { + $assetName = "files-$platform.zip" + $currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 + if (-not $currentAsset) { + throw "Current release $tag does not contain required asset $assetName" + } + + $baselineRelease = $null + if (-not [string]::IsNullOrWhiteSpace($baselineInput)) { + $normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" } + $baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1 + if (-not $baselineRelease) { + throw "Specified baseline tag not found: $normalizedBaseline" + } + } + else { + $baselineRelease = $allReleases | + Where-Object { + $_.tag_name -ne $tag -and + -not $_.draft -and + [bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and + ($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0 + } | + Select-Object -First 1 + } + + [pscustomobject]@{ + platform = $platform + assetName = $assetName + baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null } + baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null } + isFullPayload = -not $baselineRelease + } + } + + $plan = [pscustomobject]@{ + tag = $tag + version = $env:RELEASE_VERSION + channel = $env:RELEASE_CHANNEL + platforms = $entries + } + + $plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8 + Get-Content plonds-plan.json + + - name: Download payload zips + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $repo = '${{ github.repository }}' + $plan = Get-Content plonds-plan.json | ConvertFrom-Json + + foreach ($entry in $plan.platforms) { + $currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)" + New-Item -ItemType Directory -Path $currentDir -Force | Out-Null + gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir + + if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) { + $baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)" + New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null + gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir + } + } + + - name: Build delta assets + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $plan = Get-Content plonds-plan.json | ConvertFrom-Json + foreach ($entry in $plan.platforms) { + $currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)" + $args = @( + 'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--', + 'build-delta', + '--platform', $entry.platform, + '--current-version', $plan.version, + '--current-tag', $plan.tag, + '--current-zip', $currentZip, + '--output-dir', 'plonds-output', + '--private-key', $env:UPDATE_PRIVATE_KEY_PATH, + '--channel', $plan.channel + ) + + if ([bool]$entry.isFullPayload) { + $args += @('--is-full-payload', 'true') + } + else { + $baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)" + $args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip) + } + + dotnet @args + } + + dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- ` + build-index ` + --release-tag $plan.tag ` + --version $plan.version ` + --channel $plan.channel ` + --platform-summaries-dir plonds-output/platform-summaries ` + --output-dir plonds-output ` + --private-key $env:UPDATE_PRIVATE_KEY_PATH + + - name: Upload PLONDS assets to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber + + - name: Persist run metadata + shell: bash + run: | + mkdir -p plonds-run-metadata + printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt + + - name: Upload run metadata artifact + uses: actions/upload-artifact@v4 + with: + name: plonds-run-metadata + path: plonds-run-metadata/tag.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5725440..521274c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,23 +15,6 @@ 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' @@ -47,6 +30,8 @@ jobs: informational_version: ${{ steps.version.outputs.informational_version }} tag: ${{ steps.version.outputs.tag }} checkout_ref: ${{ steps.version.outputs.checkout_ref }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + release_channel: ${{ steps.version.outputs.release_channel }} steps: - name: Checkout repository metadata @@ -60,6 +45,7 @@ jobs: if [[ "${{ github.event_name }}" == "push" ]]; then TAG="${GITHUB_REF#refs/tags/}" CHECKOUT_REF="${GITHUB_REF}" + IS_PRERELEASE="false" else RAW_TAG="${{ github.event.inputs.tag }}" if [[ "${RAW_TAG}" == refs/tags/* ]]; then @@ -69,23 +55,40 @@ jobs: else TAG="v${RAW_TAG}" fi + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then CHECKOUT_REF="refs/tags/${TAG}" else CHECKOUT_REF="${GITHUB_SHA}" fi + + if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then + IS_PRERELEASE="true" + else + IS_PRERELEASE="false" + fi fi + VERSION="${TAG#v}" IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}" while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do VERSION_PARTS+=("0") done ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}" - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT - echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT - echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT + + if [[ "${IS_PRERELEASE}" == "true" ]]; then + RELEASE_CHANNEL="preview" + else + RELEASE_CHANNEL="stable" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT" + echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT" + echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT" + echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT" build-windows: needs: prepare @@ -133,9 +136,6 @@ jobs: $arch = "${{ matrix.arch }}" $launcherPublishDir = "publish/launcher-win-$arch" - Write-Host "Publishing Launcher with AOT for Windows $arch..." - - # AOT publish dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` -c Release ` -o ./$launcherPublishDir ` @@ -156,21 +156,6 @@ jobs: 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) { - $size = [Math]::Round($exeFile.Length / 1MB, 2) - Write-Host "Launcher executable: $($exeFile.Name) ($size MB)" - } - - # Warn if unexpected extra files are produced - $files = Get-ChildItem -Path $launcherPublishDir -File - if ($files.Count -gt 1) { - Write-Host "Warning: Expected single file but found $($files.Count) files" - $files | ForEach-Object { Write-Host " - $($_.Name)" } - } shell: pwsh - name: Publish Main App @@ -208,9 +193,6 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} } - - Write-Host "Published to: $publishDir" - Write-Host "Self-contained: $selfContained" shell: pwsh - name: Restructure for Launcher @@ -221,30 +203,18 @@ jobs: $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $launcherPublishDir = "publish/launcher-win-$arch" $appDir = "app-$version" - - Write-Host "Restructuring for Launcher mode..." - Write-Host "Version: $version" - Write-Host "Publish dir: $publishDir" - $newStructure = "publish-launcher/windows-$arch" - New-Item -ItemType Directory -Path $newStructure -Force | Out-Null + New-Item -ItemType Directory -Path $newStructure -Force | Out-Null $appPath = Join-Path $newStructure $appDir Move-Item -Path $publishDir -Destination $appPath -Force - $launcherSource = $launcherPublishDir - if (Test-Path $launcherSource) { - Write-Host "Copying Launcher to root..." - Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force - } else { - Write-Warning "Launcher publish dir not found: $launcherSource" + if (Test-Path $launcherPublishDir) { + Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force } New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null - Write-Host "New directory structure:" - Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName - Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue Move-Item -Path $newStructure -Destination $publishDir -Force @@ -260,61 +230,27 @@ jobs: run: | $version = "${{ needs.prepare.outputs.version }}" $arch = "${{ matrix.arch }}" - $selfContained = "${{ matrix.self_contained }}" -eq "true" $suffix = "${{ matrix.suffix }}" - $publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" } - $installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } $outputDir = "build-installer" - - if (-not (Test-Path -Path $publishDir)) { - Write-Error "Publish directory not found: $publishDir" - Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name - exit 1 - } + $installerScript = "LanMountainDesktop/packaging/windows/LanMountainDesktop.iss" New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - if (-not (Test-Path -Path $installerScript)) { - Write-Error "Installer script not found: $installerScript" - exit 1 - } - - $isccPath = $null - $isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue - if ($isccCommand) { - $isccPath = $isccCommand.Source - } - $candidatePaths = @( - "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", - "C:\Program Files\Inno Setup 6\ISCC.exe", - "$env:ChocolateyInstall\bin\ISCC.exe", + (Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), + "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", "$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe" - ) + ) | Where-Object { $_ -and (Test-Path $_) } + $isccPath = $candidatePaths | Select-Object -First 1 if (-not $isccPath) { - foreach ($candidate in $candidatePaths) { - if ($candidate -and (Test-Path -Path $candidate)) { - $isccPath = $candidate - break - } - } - } - - if (-not $isccPath) { - Write-Host "ISCC.exe was not found in PATH or known locations." - Write-Host "Checked locations:" - $candidatePaths | ForEach-Object { Write-Host " - $_" } - Write-Host "Chocolatey bin listing (if exists):" - Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName Write-Error "Inno Setup compiler not found." exit 1 } - Write-Host "Found Inno Setup at: $isccPath" - - Write-Host "Building installer for Windows $arch with version $version..." - $publishDir = (Resolve-Path $publishDir).Path $outputDir = (Resolve-Path $outputDir).Path $installerScript = (Resolve-Path $installerScript).Path @@ -329,8 +265,6 @@ jobs: $installerScript ) - Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')" - & $isccPath @compileArgs if ($LASTEXITCODE -ne 0) { Write-Error "Inno Setup compiler exited with code $LASTEXITCODE" @@ -342,25 +276,53 @@ jobs: Write-Error "Failed to create installer" exit 1 } - - Write-Host "Successfully created: $($installerFile.Name)" - Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh - - name: Upload App Payload - uses: actions/upload-artifact@v4 - with: - name: app-payload-windows-${{ matrix.arch }} - path: | - publish/windows-${{ matrix.arch }}/** - if-no-files-found: error - retention-days: 30 + - name: Package Payload Zip + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version" + if (-not (Test-Path $payloadRoot)) { + Write-Error "Payload root not found: $payloadRoot" + exit 1 + } - - name: Upload Installer + $stageDir = Join-Path $PWD "payload-stage/windows-$arch" + $releaseDir = Join-Path $PWD "release-assets" + Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + + Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object { + $relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/') + if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) { + return + } + + $destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar) + $destinationDir = Split-Path -Path $destination -Parent + if (-not [string]::IsNullOrWhiteSpace($destinationDir)) { + New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null + } + + Copy-Item -Path $_.FullName -Destination $destination -Force + } + + $payloadZip = Join-Path $releaseDir "files-windows-$arch.zip" + if (Test-Path $payloadZip) { + Remove-Item $payloadZip -Force + } + Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force + shell: pwsh + + - name: Upload Release Assets uses: actions/upload-artifact@v4 with: - name: installer-windows-${{ matrix.arch }} - path: build-installer/*.exe + name: release-windows-${{ matrix.arch }} + path: | + release-assets/files-windows-${{ matrix.arch }}.zip + build-installer/*.exe if-no-files-found: error retention-days: 30 @@ -385,13 +347,10 @@ jobs: libx11-6 libxrandr2 libxinerama1 \ libxi6 libxcursor1 libxext6 \ libxrender1 libxkbcommon-x11-0 \ - clang zlib1g-dev + clang zlib1g-dev zip rsync - # Ubuntu 24.04+ moved several packages to t64 names. sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2 sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2 - - # Prefer modern WebKit package, fallback for older images. sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev - name: Setup .NET @@ -413,8 +372,6 @@ 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 \ @@ -431,14 +388,6 @@ jobs: -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/ - - name: Publish Main App run: | dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ @@ -464,25 +413,15 @@ jobs: appDir="app-$version" launcherDir="publish/launcher-linux-x64" - echo "Restructuring for Launcher mode..." - echo "Version: $version" - mkdir -p "$publishDir" mv "publish/linux-x64-app" "$publishDir/$appDir" if [ -d "$launcherDir" ]; then - echo "Copying Launcher to root..." cp -r "$launcherDir"/* "$publishDir/" chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true - else - echo "Warning: Launcher publish dir not found: $launcherDir" fi touch "$publishDir/$appDir/.current" - - echo "New directory structure:" - find "$publishDir" -maxdepth 2 | head -50 - rm -rf "$launcherDir" - name: Package as DEB @@ -495,12 +434,6 @@ jobs: desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" - if [ ! -d "$source" ]; then - echo "Error: Source directory not found: $source" - ls -la publish/ || echo "publish directory not found" - exit 1 - fi - mkdir -p "build-deb/DEBIAN" mkdir -p "build-deb/usr/local/bin" mkdir -p "build-deb/usr/share/applications" @@ -509,20 +442,6 @@ jobs: cp -r "$source"/* "build-deb/usr/local/bin/" - item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l) - echo "DEB package contains $item_count files" - - if [ "$item_count" -eq 0 ]; then - echo "Error: DEB package is empty after copy" - exit 1 - fi - - if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then - echo "Error: Linux desktop resources are missing" - ls -la "LanMountainDesktop/packaging/linux" || true - exit 1 - fi - sed \ -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \ -e "s|@@ICON@@|lanmountaindesktop|g" \ @@ -546,9 +465,9 @@ jobs: printf '%s\n' "Package: $package_name" printf '%s\n' "Version: $package_version" printf '%s\n' "Architecture: $arch" - printf '%s\n' "Maintainer: LanMountain Team " - printf '%s\n' "Description: LanMountain Desktop Application" - printf '%s\n' " A desktop application for LanMountain." + printf '%s\n' 'Maintainer: LanMountain Team ' + printf '%s\n' 'Description: LanMountain Desktop Application' + printf '%s\n' ' A desktop application for LanMountain.' } > "build-deb/DEBIAN/control" chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/* @@ -557,35 +476,49 @@ jobs: chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" chmod 755 "build-deb/DEBIAN/postinst" - if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then - echo "Successfully created: ${package_name}_${package_version}_${arch}.deb" - ls -lh "${package_name}_${package_version}_${arch}.deb" - else - echo "Error: Failed to build DEB package" + dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb" + + - name: Package Payload Zip + run: | + version="${{ needs.prepare.outputs.version }}" + payload_root="publish/linux-x64/app-$version" + release_dir="$PWD/release-assets" + stage_dir="$PWD/payload-stage/linux-x64" + + if [ ! -d "$payload_root" ]; then + echo "Payload root not found: $payload_root" exit 1 fi - - name: Upload App Payload - uses: actions/upload-artifact@v4 - with: - name: app-payload-linux-x64 - path: | - publish/linux-x64/** - if-no-files-found: error - retention-days: 30 + rm -rf "$stage_dir" + mkdir -p "$stage_dir" "$release_dir" + rsync -a \ + --exclude '.current' \ + --exclude '.partial' \ + --exclude '.destroy' \ + "$payload_root/" "$stage_dir/" - - name: Upload Installer + ( + cd "$stage_dir" + zip -qr "$release_dir/files-linux-x64.zip" . + ) + + - name: Upload Release Assets uses: actions/upload-artifact@v4 with: - name: installer-linux-x64 - path: "*.deb" + name: release-linux-x64 + path: | + release-assets/files-linux-x64.zip + *.deb if-no-files-found: error retention-days: 30 build-macos: needs: prepare runs-on: macos-latest + continue-on-error: true strategy: + fail-fast: false matrix: arch: [x64, arm64] name: Build_macOS_${{ matrix.arch }} @@ -620,8 +553,6 @@ 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 }} \ @@ -638,14 +569,6 @@ jobs: -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 }}/ - - name: Publish Main App run: | dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \ @@ -664,7 +587,22 @@ jobs: -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \ -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + - name: Package Payload Zip + run: | + release_dir="$PWD/release-assets" + stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}" + payload_root="publish/macos-${{ matrix.arch }}-app" + + rm -rf "$stage_dir" + mkdir -p "$stage_dir" "$release_dir" + rsync -a "$payload_root/" "$stage_dir/" + ( + cd "$stage_dir" + zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" . + ) + - name: Restructure and Package as DMG + continue-on-error: true run: | version="${{ needs.prepare.outputs.version }}" arch="${{ matrix.arch }}" @@ -673,41 +611,19 @@ jobs: launcherDir="publish/launcher-macos-$arch" appSourceDir="publish/macos-$arch-app" - echo "Restructuring for Launcher mode..." - echo "Version: $version" - mkdir -p "${app_name}.app/Contents/MacOS" - appDir="app-$version" mkdir -p "${app_name}.app/Contents/MacOS/$appDir" - if [ -d "$appSourceDir" ]; then - cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" - else - echo "Error: Main app source directory not found: $appSourceDir" - exit 1 - fi - + cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/" if [ -d "$launcherDir" ]; then - echo "Copying Launcher to root..." cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/" chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true - else - echo "Warning: Launcher publish dir not found: $launcherDir" fi touch "${app_name}.app/Contents/MacOS/$appDir/.current" - mkdir -p "${app_name}.app/Contents/Resources" - item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l) - echo "App bundle contains $item_count files" - - if [ "$item_count" -eq 0 ]; then - echo "Error: App bundle is empty after copy" - exit 1 - fi - { printf '%s\n' '' printf '%s\n' '' @@ -731,306 +647,73 @@ jobs: mkdir -p dmg-temp cp -r "${app_name}.app" dmg-temp/ + hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" - if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then - echo "Successfully created: ${package_name}.dmg" - ls -lh "${package_name}.dmg" - else - echo "Error: Failed to create DMG" - exit 1 - fi - - rm -rf dmg-temp "${app_name}.app" - - - name: Upload + - name: Upload Release Assets + if: always() uses: actions/upload-artifact@v4 with: - name: installer-macos-${{ matrix.arch }} - path: "*.dmg" - if-no-files-found: error + name: release-macos-${{ matrix.arch }} + path: | + release-assets/files-macos-${{ matrix.arch }}.zip + *.dmg + if-no-files-found: ignore retention-days: 30 - publish-plonds: - needs: [ prepare, build-windows, build-linux ] - runs-on: ubuntu-latest - permissions: - contents: read - env: - VERSION: ${{ needs.prepare.outputs.version }} - S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} - S3_BUCKET: ${{ vars.S3_BUCKET }} - S3_REGION: ${{ vars.S3_REGION }} - UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} - PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }} - PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }} - 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: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ needs.prepare.outputs.checkout_ref }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - dotnet-quality: 'preview' - - - name: Download app payload artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts/app-payload - pattern: app-payload-* - - - name: Download installer artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts/installers - pattern: installer-* - - - name: Prepare signing key - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - - function Test-PemKey { - param([string]$PemText) - - if ([string]::IsNullOrWhiteSpace($PemText)) { - return $false - } - - $rsa = [System.Security.Cryptography.RSA]::Create() - try { - $rsa.ImportFromPem($PemText) - return $true - } - catch { - return $false - } - finally { - $rsa.Dispose() - } - } - - $candidates = @( - $env:PLONDS_SIGNING_KEY, - $env:UPDATE_PRIVATE_KEY_PEM, - $env:PDC_SIGNING_KEY - ) - - $key = $null - foreach ($candidate in $candidates) { - if (Test-PemKey $candidate) { - $key = $candidate - break - } - } - - if ([string]::IsNullOrWhiteSpace($key)) { - throw "Missing a valid PEM signing key in PLONDS_SIGNING_KEY, UPDATE_PRIVATE_KEY_PEM, or PDC_SIGNING_KEY." - } - - $keyPath = Join-Path $PWD "update-private-key.pem" - [System.IO.File]::WriteAllText($keyPath, $key, [System.Text.Encoding]::ASCII) - Add-Content -Path $env:GITHUB_ENV -Value "UPDATE_PRIVATE_KEY_PATH=$keyPath" - - - name: Probe S3 access - if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }} - shell: bash - run: | - set -euo pipefail - aws --version - aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 ls "s3://$S3_BUCKET" >/dev/null - echo "S3 access probe succeeded for $S3_BUCKET" - - - name: Build PLONDS assets - 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 ` - -AppArtifactsRoot (Join-Path $PWD "artifacts/app-payload") ` - -InstallerArtifactsRoot (Join-Path $PWD "artifacts/installers") ` - -OutputDir (Join-Path $PWD "plonds-output") ` - -PrivateKeyPath $env:UPDATE_PRIVATE_KEY_PATH ` - -Channel "stable" ` - -S3Endpoint $env:S3_ENDPOINT ` - -S3Bucket $env:S3_BUCKET ` - -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 - with: - name: plonds-assets - path: | - plonds-output/release-assets/** - plonds-output/published/** - if-no-files-found: error - retention-days: 90 github-release: - needs: [ prepare, build-windows, build-linux, build-macos, publish-plonds ] + needs: [prepare, build-windows, build-linux] runs-on: ubuntu-latest permissions: contents: write steps: - - name: Download installer artifacts + - name: Download release artifacts uses: actions/download-artifact@v4 with: - path: artifacts/installers - pattern: installer-* + path: release-files + pattern: release-* + merge-multiple: true - - name: Download PLONDS artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts/plonds - pattern: plonds-assets - - - name: List artifacts structure + - name: Validate release files run: | - echo "Artifact directory structure:" - find artifacts -type f -o -type d | sort - echo "" - echo "Files found:" - find artifacts -type f -exec ls -lh {} \; - echo "" - echo "Full tree:" - tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g' + echo "Release files:" + find release-files -maxdepth 1 -type f -exec ls -lh {} \; - - name: Flatten artifacts for release - run: | - 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" -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" - echo "" - echo "Total files:" - file_count=$(find release-files -type f | wc -l) - echo "$file_count" - if [ "$file_count" -eq 0 ]; then - echo "Error: No release files found" + if [ ! -f release-files/files-windows-x64.zip ] || [ ! -f release-files/files-windows-x86.zip ] || [ ! -f release-files/files-linux-x64.zip ]; then + echo "Required payload zips are missing." exit 1 fi - - name: Create Release + file_count=$(find release-files -maxdepth 1 -type f | wc -l) + if [ "$file_count" -eq 0 ]; then + echo "No release files were produced." + exit 1 + fi + + - name: Create or Update Release uses: ncipollo/release-action@v1 with: tag: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.tag }} - commit: ${{ github.sha }} allowUpdates: true draft: false - prerelease: ${{ github.event.inputs.is_prerelease == 'true' }} - artifacts: "release-files/**" + prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} + artifacts: 'release-files/**' body: | ## Release ${{ needs.prepare.outputs.version }} - ### Windows - - **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime) - - **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime) + ### Installers + - `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe` + - `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe` + - `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb` - **Note:** The Launcher is now built with AOT (Ahead-of-Time) compilation as a single executable file for faster startup and smaller footprint. - - Installation: Double-click the .exe file and follow the wizard. - - ### 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** - - Existing users: Host will prefer staged PLONDS payloads and keep the Launcher responsible for apply + rollback. Legacy signed file-map assets remain attached as a fallback path. - - ### Linux - - **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64) + ### Payload Archives + - `files-windows-x64.zip` + - `files-windows-x86.zip` + - `files-linux-x64.zip` ### macOS - - **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor - - **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3) + - macOS assets are best-effort and will not block the release. - See commits for changes. + Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows. 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.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index fa0ee07..04a86b3 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -465,6 +465,7 @@ internal sealed class UpdateEngineService } File.Copy(sourcePath, targetPath, overwrite: true); + ApplyUnixFileModeIfPresent(targetPath, file); return; } @@ -472,6 +473,7 @@ internal sealed class UpdateEngineService var objectBytes = File.ReadAllBytes(objectPath); var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes; File.WriteAllBytes(targetPath, restoredBytes); + ApplyUnixFileModeIfPresent(targetPath, file); } private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment) @@ -914,6 +916,29 @@ internal sealed class UpdateEngineService metadata["component"] = componentName; } + if (TryGetJsonPropertyIgnoreCase(node, "metadata", out var metadataNode) && + metadataNode.ValueKind == JsonValueKind.Object) + { + foreach (var property in metadataNode.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.Null || + property.Value.ValueKind == JsonValueKind.Undefined) + { + continue; + } + + var value = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + metadata[property.Name] = value; + } + } + entry = new PlondsFileEntry { Path = path, @@ -954,6 +979,31 @@ internal sealed class UpdateEngineService return true; } + private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) || + string.IsNullOrWhiteSpace(rawMode)) + { + return; + } + + try + { + var normalized = rawMode.Trim(); + var modeValue = Convert.ToInt32(normalized, 8); + File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue); + } + catch + { + // Best-effort only. A bad mode should not break the entire update. + } + } + private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) { if (node.ValueKind == JsonValueKind.Object) diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index 4bd6c48..cba74d5 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -44,7 +44,10 @@ public sealed record PlondsUpdatePayload( string? FileMapJson, string? FileMapSignature, string? FileMapJsonUrl, - string? FileMapSignatureUrl); + string? FileMapSignatureUrl, + string? UpdateArchiveUrl = null, + string? UpdateArchiveSha256 = null, + long? UpdateArchiveSizeBytes = null); public sealed record UpdateDownloadResult( bool Success, @@ -159,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable var preferredAsset = isUpdateAvailable ? SelectPreferredInstallerAsset(release.Assets) : null; + var plondsPayload = isUpdateAvailable + ? TryResolvePlondsPayload(release) + : null; return new UpdateCheckResult( Success: true, @@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable LatestVersionText: latestVersionText, Release: release, PreferredAsset: preferredAsset, - ErrorMessage: null); + ErrorMessage: null, + PlondsPayload: plondsPayload); } catch (OperationCanceledException) { @@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable : release.TagName; var preferredAsset = SelectPreferredInstallerAsset(release.Assets); + var plondsPayload = TryResolvePlondsPayload(release); return new UpdateCheckResult( Success: true, @@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable Release: release, PreferredAsset: preferredAsset, ErrorMessage: null, - ForceMode: true); + ForceMode: true, + PlondsPayload: plondsPayload); } catch (OperationCanceledException) { @@ -652,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList assets) { - if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows()) + if (assets is null || assets.Count == 0) { return null; } @@ -664,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable _ => "x64" }; - var ranked = assets - .Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken))) - .OrderByDescending(x => x.Score) - .ToList(); + if (OperatingSystem.IsWindows()) + { + return assets + .Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } - return ranked.FirstOrDefault(x => x.Score > 0).Asset; + if (OperatingSystem.IsLinux()) + { + return assets + .Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } + + if (OperatingSystem.IsMacOS()) + { + return assets + .Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } + + return null; + } + + private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release) + { + if (release.Assets is null || release.Assets.Count == 0) + { + return null; + } + + var platformSuffix = GetPlatformAssetSuffix(); + var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json"); + var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig") + ?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig"); + var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip"); + if (fileMapAsset is null || signatureAsset is null || archiveAsset is null) + { + return null; + } + + var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}"; + var channelId = release.IsPrerelease + ? UpdateSettingsValues.ChannelPreview + : UpdateSettingsValues.ChannelStable; + + return new PlondsUpdatePayload( + DistributionId: distributionId, + ChannelId: channelId, + SubChannel: platformSuffix, + FileMapJson: null, + FileMapSignature: null, + FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl, + FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl, + UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl, + UpdateArchiveSha256: archiveAsset.Sha256, + UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null); + } + + private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string assetName) + { + return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase)); + } + + private static string GetPlatformAssetSuffix() + { + var os = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsLinux() + ? "linux" + : OperatingSystem.IsMacOS() + ? "macos" + : "unknown"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "x86", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => "x64" + }; + + return $"{os}-{arch}"; } private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken) @@ -719,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable return score; } + private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + return 0; + } + + var score = 0; + + if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)) + { + score += 220; + } + else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)) + { + score += 180; + } + else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase)) + { + score += 160; + } + else + { + return 0; + } + + if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase)) + { + score += 40; + } + + if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) || + (architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase))) + { + score += 40; + } + else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase)) + { + score -= 30; + } + + return score; + } + + private static int ScoreMacInstallerAsset(string assetName, string architectureToken) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + return 0; + } + + var score = 0; + + if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)) + { + score += 220; + } + else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase)) + { + score += 180; + } + else + { + return 0; + } + + if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("osx", StringComparison.OrdinalIgnoreCase)) + { + score += 40; + } + + if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase)) + { + score += 40; + } + else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase)) + { + score -= 30; + } + + return score; + } + private static bool TryParseVersion(string? value, out Version? version) { version = null; diff --git a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs index 75c0916..a22e60b 100644 --- a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs @@ -1,52 +1,17 @@ -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; +using System; using System.Threading; using System.Threading.Tasks; namespace LanMountainDesktop.Services; /// -/// Thin PLONDS client used by the host app. -/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads. +/// Release-backed PLONDS checker. +/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets. +/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow. /// 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; - - public PlondsReleaseUpdateService(HttpClient? httpClient = null) - { - if (httpClient is null) - { - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(20) - }; - _ownsHttpClient = true; - } - else - { - _httpClient = httpClient; - _ownsHttpClient = false; - } - } - - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } + private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); public Task CheckForUpdatesAsync( Version currentVersion, @@ -64,829 +29,52 @@ public sealed class PlondsReleaseUpdateService : IDisposable return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); } + public void Dispose() + { + _githubReleaseUpdateService.Dispose(); + } + private async Task CheckForUpdatesCoreAsync( Version currentVersion, bool includePrerelease, bool isForce, CancellationToken cancellationToken) { - var normalizedCurrentVersion = NormalizeVersion(currentVersion); - var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion); - var endpoint = ResolveEndpoint(); - var latestVersionText = "-"; + var releaseResult = isForce + ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - if (string.IsNullOrWhiteSpace(endpoint)) + if (!releaseResult.Success) { - return new UpdateCheckResult( - Success: false, - IsUpdateAvailable: false, - CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: latestVersionText, - Release: null, - PreferredAsset: null, - ErrorMessage: "PLONDS endpoint is not configured.", - ForceMode: isForce); + return releaseResult; } - try + if (!isForce && !releaseResult.IsUpdateAvailable) { - var apiBasePath = ResolveApiBasePath(); - var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata"); - var channelId = ResolveChannelId(includePrerelease); - var platform = ResolvePlatform(); - var latestUrl = BuildApiUrl( - endpoint, - apiBasePath, - $"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}"); - - _ = 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, - IsUpdateAvailable: false, - CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: normalizedCurrentVersionText, - Release: null, - PreferredAsset: null, - ErrorMessage: null, - ForceMode: isForce); - } - - latestVersionText = latestDescriptor.VersionText; - var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion; - if (!isForce && !hasUpdate) - { - return new UpdateCheckResult( - Success: true, - IsUpdateAvailable: false, - CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: latestVersionText, - Release: null, - PreferredAsset: null, - ErrorMessage: null, - ForceMode: false); - } - - var distribution = await ResolveDistributionAsync( - endpoint, - apiBasePath, - latestUrl, - latestDescriptor, - channelId, - platform, - cancellationToken).ConfigureAwait(false); - - latestVersionText = distribution.Latest.VersionText; - - var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow; - var release = new GitHubReleaseInfo( - TagName: $"v{distribution.Latest.VersionText}", - Name: $"PLONDS Distribution {distribution.Latest.VersionText}", - IsPrerelease: includePrerelease, - IsDraft: false, - PublishedAt: publishedAt, - Assets: distribution.Assets); - - return new UpdateCheckResult( - Success: true, - IsUpdateAvailable: true, - CurrentVersionText: normalizedCurrentVersionText, - LatestVersionText: distribution.Latest.VersionText, - Release: release, - PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets), - ErrorMessage: null, - ForceMode: isForce, - PlondsPayload: distribution.Payload); - } - catch (OperationCanceledException) - { - throw; - } - 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, - 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}", - ForceMode: isForce); - } - } - - 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); - } + return releaseResult with { ForceMode = 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(); - if (!string.IsNullOrWhiteSpace(token)) + if (releaseResult.PlondsPayload is not null) { - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + return releaseResult with { ForceMode = isForce }; } - HttpResponseMessage response; - try - { - 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 (response) - { - 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}'."); - } - - 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) - { - var assets = new List(); - - if (TryGetPropertyIgnoreCase(distributionNode, "installerMirrors", out var installersNode) && - installersNode.ValueKind == JsonValueKind.Array) - { - foreach (var installerNode in installersNode.EnumerateArray()) - { - if (installerNode.ValueKind != JsonValueKind.Object) - { - continue; - } - - var name = ReadString(installerNode, "name") - ?? ReadString(installerNode, "fileName"); - var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl"); - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) - { - continue; - } - - var size = ReadInt64(installerNode, "size") ?? 0L; - var sha256 = ReadString(installerNode, "sha256"); - assets.Add(new GitHubReleaseAsset(name, url, size, sha256)); - } - } - - if (assets.Count > 0) - { - return assets; - } - - if (TryGetPropertyIgnoreCase(distributionNode, "assets", out var assetsNode) && - assetsNode.ValueKind == JsonValueKind.Array) - { - foreach (var assetNode in assetsNode.EnumerateArray()) - { - if (assetNode.ValueKind != JsonValueKind.Object) - { - continue; - } - - var name = ReadString(assetNode, "name"); - var url = ReadString(assetNode, "url") - ?? ReadString(assetNode, "downloadUrl") - ?? ReadString(assetNode, "browserDownloadUrl"); - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) - { - continue; - } - - var size = ReadInt64(assetNode, "size") ?? 0L; - var sha256 = ReadString(assetNode, "sha256"); - assets.Add(new GitHubReleaseAsset(name, url, size, sha256)); - } - } - - return assets; - } - - private static PlondsUpdatePayload ResolvePlondsPayload( - JsonElement distributionNode, - string distributionId, - string channelId, - string platform) - { - var fileMapJson = ReadString(distributionNode, "fileMapJson"); - var fileMapSignature = ReadString(distributionNode, "fileMapSignature"); - var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl") - ?? ReadString(distributionNode, "fileMapUrl") - ?? ReadString(distributionNode, "manifestUrl"); - var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl") - ?? ReadString(distributionNode, "signatureUrl"); - - return new PlondsUpdatePayload( - DistributionId: distributionId, - ChannelId: channelId, - SubChannel: platform, - FileMapJson: fileMapJson, - FileMapSignature: fileMapSignature, - FileMapJsonUrl: fileMapJsonUrl, - FileMapSignatureUrl: fileMapSignatureUrl); - } - - private static bool HasPlondsPayload(PlondsUpdatePayload payload) - { - return !string.IsNullOrWhiteSpace(payload.FileMapJson) - || !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl); - } - - private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList assets) - { - if (assets is null || assets.Count == 0) - { - return null; - } - - if (OperatingSystem.IsWindows()) - { - var archToken = RuntimeInformation.OSArchitecture switch - { - Architecture.Arm64 => "arm64", - Architecture.X86 => "x86", - _ => "x64" - }; - - return assets - .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken))) - .OrderByDescending(x => x.Score) - .FirstOrDefault(x => x.Score > 0) - .Asset; - } - - if (OperatingSystem.IsLinux()) - { - return assets - .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64"))) - .OrderByDescending(x => x.Score) - .FirstOrDefault(x => x.Score > 0) - .Asset; - } - - if (OperatingSystem.IsMacOS()) - { - var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; - return assets - .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken))) - .OrderByDescending(x => x.Score) - .FirstOrDefault(x => x.Score > 0) - .Asset; - } - - return null; - } - - private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken) - { - if (string.IsNullOrWhiteSpace(name)) - { - return 0; - } - - var score = 0; - if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase)) - { - score += 200; - } - else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase)) - { - score += 160; - } - else - { - return 0; - } - - if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) || - name.Contains("installer", StringComparison.OrdinalIgnoreCase)) - { - score += 50; - } - - if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase)) - { - score += 40; - } - - if (name.Contains("portable", StringComparison.OrdinalIgnoreCase)) - { - score -= 30; - } - - return score; - } - - private static string ResolveChannelId(bool includePrerelease) - { - return includePrerelease - ? UpdateSettingsValues.ChannelPreview - : UpdateSettingsValues.ChannelStable; - } - - private static string ResolvePlatform() - { - var os = OperatingSystem.IsWindows() - ? "windows" - : OperatingSystem.IsLinux() - ? "linux" - : OperatingSystem.IsMacOS() - ? "macos" - : "unknown"; - - var arch = RuntimeInformation.OSArchitecture switch - { - Architecture.X86 => "x86", - Architecture.Arm => "arm", - Architecture.Arm64 => "arm64", - _ => "x64" - }; - - return $"{os}-{arch}"; - } - - private static string? ResolveEndpoint() - { - var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_ENDPOINT") - ?? Environment.GetEnvironmentVariable("PLONDS_ENDPOINT"); - return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/'); - } - - private static string? ResolveToken() - { - var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_TOKEN") - ?? Environment.GetEnvironmentVariable("PLONDS_TOKEN"); - return string.IsNullOrWhiteSpace(token) ? null : token.Trim(); - } - - private static string ResolveApiBasePath() - { - var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_API_BASE_PATH") - ?? Environment.GetEnvironmentVariable("PLONDS_API_BASE_PATH"); - if (string.IsNullOrWhiteSpace(configured)) - { - return DefaultApiBasePath; - } - - var normalized = configured.Trim(); - return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : "/" + normalized; - } - - private static string BuildApiUrl(string endpoint, string apiBasePath, string relativePath) - { - return $"{endpoint.TrimEnd('/')}/{apiBasePath.Trim('/').TrimEnd('/')}/{relativePath.TrimStart('/')}"; - } - - private static string? ReadString(JsonElement node, string propertyName) - { - if (!TryGetPropertyIgnoreCase(node, propertyName, out var value)) - { - return null; - } - - return value.ValueKind == JsonValueKind.String - ? value.GetString() - : value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined - ? null - : value.ToString(); - } - - private static long? ReadInt64(JsonElement node, string propertyName) - { - if (!TryGetPropertyIgnoreCase(node, propertyName, out var value)) - { - return null; - } - - if (value.TryGetInt64(out var number)) - { - return number; - } - - var text = value.ToString(); - return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - ? parsed - : null; - } - - private static DateTimeOffset? ParsePublishedAt(JsonElement node) - { - var text = ReadString(node, "publishedAt"); - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value) - ? value - : null; - } - - private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) - { - if (node.ValueKind == JsonValueKind.Object) - { - foreach (var property in node.EnumerateObject()) - { - if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) - { - value = property.Value; - return true; - } - } - } - - value = default; - return false; - } - - private static bool TryParseVersion(string? value, out Version? version) - { - version = null; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - var normalized = value.Trim().TrimStart('v', 'V'); - var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']); - if (separatorIndex > 0) - { - normalized = normalized[..separatorIndex]; - } - - if (!Version.TryParse(normalized, out var parsed)) - { - return false; - } - - version = NormalizeVersion(parsed); - return true; - } - - private static Version NormalizeVersion(Version version) - { - var major = Math.Max(0, version.Major); - var minor = Math.Max(0, version.Minor); - var build = Math.Max(0, version.Build >= 0 ? version.Build : 0); - var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0); - return revision > 0 - ? new Version(major, minor, build, revision) - : new Version(major, minor, build); - } - - private static string FormatVersionText(Version version) - { - return version.Revision > 0 - ? version.ToString(4) - : version.ToString(3); - } - - private static string Truncate(string value, int maxLength) - { - if (string.IsNullOrEmpty(value) || value.Length <= maxLength) - { - return value; - } - - 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; } + var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText) + ? "-" + : releaseResult.LatestVersionText; + var message = releaseResult.Release is null + ? "GitHub Release data is unavailable for PLONDS." + : $"Release {latestVersion} does not expose platform PLONDS assets yet."; + + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: releaseResult.IsUpdateAvailable, + CurrentVersionText: releaseResult.CurrentVersionText, + LatestVersionText: latestVersion, + Release: releaseResult.Release, + PreferredAsset: releaseResult.PreferredAsset, + ErrorMessage: message, + ForceMode: isForce, + PlondsPayload: null); } } diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index e8479c9..59d3a9a 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; @@ -64,6 +65,7 @@ public sealed class UpdateWorkflowService private const string PlondsFileMapName = "plonds-filemap.json"; private const string PlondsFileMapSignatureName = "plonds-filemap.sig"; private const string PlondsUpdateStateName = "plonds-update.json"; + private const string PlondsUpdateArchiveName = "plonds-update.zip"; private static readonly HttpClient PlondsHttpClient = new() { @@ -302,47 +304,65 @@ public sealed class UpdateWorkflowService "filemap-download", cancellationToken); - IReadOnlyList downloadEntries; - try + IReadOnlyList objectResults; + if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl)) { - downloadEntries = ParsePlondsDownloadEntries(fileMapJson); + progress?.Report(2d / 3d); + objectResults = await EnsurePlondsArchiveObjectsAsync( + payload, + incomingDir, + objectsDir, + state.UpdateDownloadSource, + downloadThreads, + progress, + cancellationToken); } - catch (JsonException ex) + else { - throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex); - } - - if (downloadEntries.Count == 0) - { - throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects."); - } - - var expectedObjectCount = downloadEntries.Count; - var completedItems = 2; - progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2)); - - var objectResults = new List(expectedObjectCount); - var objectTargets = new HashSet(StringComparer.OrdinalIgnoreCase); - var totalSteps = expectedObjectCount + 2; - - foreach (var entry in downloadEntries) - { - if (!objectTargets.Add(entry.ObjectHashHex)) + IReadOnlyList downloadEntries; + try { - completedItems++; - progress?.Report((double)completedItems / totalSteps); - continue; + downloadEntries = ParsePlondsDownloadEntries(fileMapJson); + } + catch (JsonException ex) + { + throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex); } - var objectInfo = await EnsurePlondsObjectAsync( - entry, - objectsDir, - downloadThreads, - cancellationToken); + if (downloadEntries.Count == 0) + { + throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects."); + } - objectResults.Add(objectInfo); - completedItems++; - progress?.Report((double)completedItems / totalSteps); + var expectedObjectCount = downloadEntries.Count; + var completedItems = 2; + progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2)); + + var downloadResults = new List(expectedObjectCount); + var objectTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var totalSteps = expectedObjectCount + 2; + + foreach (var entry in downloadEntries) + { + if (!objectTargets.Add(entry.ObjectHashHex)) + { + completedItems++; + progress?.Report((double)completedItems / totalSteps); + continue; + } + + var objectInfo = await EnsurePlondsObjectAsync( + entry, + objectsDir, + downloadThreads, + cancellationToken); + + downloadResults.Add(objectInfo); + completedItems++; + progress?.Report((double)completedItems / totalSteps); + } + + objectResults = downloadResults; } var updateState = new PlondsUpdateState( @@ -692,6 +712,91 @@ public sealed class UpdateWorkflowService lastError); } + private async Task> EnsurePlondsArchiveObjectsAsync( + PlondsUpdatePayload payload, + string incomingDirectory, + string objectsDirectory, + string downloadSource, + int downloadThreads, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl)) + { + throw new PlondsDownloadException("payload-parse", "PLONDS payload does not contain an update archive URL."); + } + + var archiveAsset = new GitHubReleaseAsset( + Name: Path.GetFileName(payload.UpdateArchiveUrl) ?? PlondsUpdateArchiveName, + BrowserDownloadUrl: payload.UpdateArchiveUrl, + SizeBytes: payload.UpdateArchiveSizeBytes ?? 0, + Sha256: payload.UpdateArchiveSha256); + var archivePath = Path.Combine(incomingDirectory, PlondsUpdateArchiveName); + var archiveProgress = progress is null + ? null + : new Progress(p => progress.Report((2d + p) / 3d)); + + var downloadResult = await _settingsFacade.Update.DownloadAssetAsync( + archiveAsset, + archivePath, + downloadSource, + downloadThreads, + archiveProgress, + cancellationToken); + + if (!downloadResult.Success) + { + downloadResult = await _settingsFacade.Update.RedownloadAssetAsync( + archiveAsset, + archivePath, + downloadSource, + downloadThreads, + archiveProgress, + cancellationToken); + } + + if (!downloadResult.Success) + { + throw new PlondsDownloadException( + "object-download", + $"Failed to download PLONDS update archive: {downloadResult.ErrorMessage}"); + } + + try + { + if (Directory.Exists(objectsDirectory)) + { + Directory.Delete(objectsDirectory, recursive: true); + } + + Directory.CreateDirectory(objectsDirectory); + ZipFile.ExtractToDirectory(archivePath, objectsDirectory, overwriteFiles: true); + } + catch (Exception ex) + { + throw new PlondsDownloadException( + "payload-parse", + $"Failed to extract PLONDS update archive: {ex.Message}", + ex); + } + finally + { + DeleteFileIfExists(archivePath); + } + + var objectResults = Directory.EnumerateFiles(objectsDirectory, "*", SearchOption.AllDirectories) + .Select(path => new PlondsDownloadedObjectInfo( + ComponentId: "app", + RelativePath: Path.GetRelativePath(objectsDirectory, path).Replace('\\', '/'), + SourceUrl: payload.UpdateArchiveUrl, + ObjectHashHex: Path.GetFileName(path), + LocalPath: path)) + .ToArray(); + + progress?.Report(1d); + return objectResults; + } + private static IReadOnlyList ParsePlondsDownloadEntries(string fileMapJson) { var entries = new List(); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssBuildOptions.cs new file mode 100644 index 0000000..8f6fe9b --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssBuildOptions.cs @@ -0,0 +1,9 @@ +namespace Plonds.Core.Publishing; + +public sealed record DdssBuildOptions( + string ReleaseTag, + string AssetsDirectory, + string OutputRoot, + string PrivateKeyPath, + string Repository, + string? S3BaseUrl = null); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssManifestBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssManifestBuilder.cs new file mode 100644 index 0000000..36c36c8 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/DdssManifestBuilder.cs @@ -0,0 +1,68 @@ +using Plonds.Core.Security; +using Plonds.Shared.Models; + +namespace Plonds.Core.Publishing; + +public sealed class DdssManifestBuilder +{ + private readonly RsaFileSigner _signer = new(); + + public string Build(DdssBuildOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var assetsDirectory = Path.GetFullPath(options.AssetsDirectory); + if (!Directory.Exists(assetsDirectory)) + { + throw new DirectoryNotFoundException($"DDSS assets directory not found: {assetsDirectory}"); + } + + var assetEntries = Directory + .EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly) + .Where(static path => + { + var name = Path.GetFileName(path); + return !name.Equals("ddss.json", StringComparison.OrdinalIgnoreCase) + && !name.Equals("ddss.json.sig", StringComparison.OrdinalIgnoreCase); + }) + .OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) + .Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl)) + .ToArray(); + + var manifest = new DdssManifest( + FormatVersion: "1.0", + ReleaseTag: options.ReleaseTag, + GeneratedAt: DateTimeOffset.UtcNow, + Assets: assetEntries); + + var outputRoot = Path.GetFullPath(options.OutputRoot); + Directory.CreateDirectory(outputRoot); + var manifestPath = Path.Combine(outputRoot, "ddss.json"); + PayloadUtilities.WriteJson(manifestPath, manifest); + _signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig"); + return manifestPath; + } + + private static DdssAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl) + { + var fileName = Path.GetFileName(assetPath); + var mirrors = new List + { + new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}") + }; + + if (!string.IsNullOrWhiteSpace(s3BaseUrl)) + { + mirrors.Add(new DdssMirrorEntry( + "s3", + $"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}")); + } + + return new DdssAssetEntry( + AssetId: fileName, + FileName: fileName, + Sha256: PayloadUtilities.ComputeSha256(assetPath), + Size: new FileInfo(assetPath).Length, + Mirrors: mirrors); + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PayloadUtilities.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PayloadUtilities.cs new file mode 100644 index 0000000..abc4d73 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PayloadUtilities.cs @@ -0,0 +1,235 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Plonds.Core.Publishing; + +public static class PayloadUtilities +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public static void CreatePayloadZip(string sourceDirectory, string outputZipPath) + { + var resolvedSourceDirectory = Path.GetFullPath(sourceDirectory); + if (!Directory.Exists(resolvedSourceDirectory)) + { + throw new DirectoryNotFoundException($"Payload source directory not found: {resolvedSourceDirectory}"); + } + + var resolvedOutputZipPath = Path.GetFullPath(outputZipPath); + var outputDirectory = Path.GetDirectoryName(resolvedOutputZipPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + if (File.Exists(resolvedOutputZipPath)) + { + File.Delete(resolvedOutputZipPath); + } + + using var archive = ZipFile.Open(resolvedOutputZipPath, ZipArchiveMode.Create); + foreach (var filePath in Directory.EnumerateFiles(resolvedSourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSourceDirectory, filePath)); + if (ShouldIgnore(relativePath)) + { + continue; + } + + archive.CreateEntryFromFile(filePath, relativePath, CompressionLevel.Optimal); + } + } + + internal static void ExtractZip(string zipPath, string destinationDirectory) + { + var resolvedZipPath = Path.GetFullPath(zipPath); + if (!File.Exists(resolvedZipPath)) + { + throw new FileNotFoundException("Payload archive not found.", resolvedZipPath); + } + + EnsureCleanDirectory(destinationDirectory); + ZipFile.ExtractToDirectory(resolvedZipPath, destinationDirectory, overwriteFiles: true); + } + + internal static Dictionary ScanDirectory(string? root) + { + var manifest = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) + { + return manifest; + } + + var resolvedRoot = Path.GetFullPath(root); + foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories)) + { + var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedRoot, filePath)); + if (ShouldIgnore(relativePath)) + { + continue; + } + + var fileInfo = new FileInfo(filePath); + manifest[relativePath] = new FileFingerprint( + relativePath, + filePath, + ComputeSha256(filePath), + fileInfo.Length, + ResolveUnixFileMode(filePath)); + } + + return manifest; + } + + internal static string CopyObject(string sourcePath, string objectsRoot, string sha256) + { + var normalizedSha256 = sha256.Trim().ToLowerInvariant(); + var prefix = normalizedSha256[..Math.Min(2, normalizedSha256.Length)]; + var relativePath = NormalizeRelativePath(Path.Combine(prefix, normalizedSha256)); + var destinationPath = Path.Combine(objectsRoot, prefix, normalizedSha256); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + if (!File.Exists(destinationPath)) + { + File.Copy(sourcePath, destinationPath, overwrite: true); + } + + return relativePath; + } + + internal static void EnsureCleanDirectory(string path) + { + var resolvedPath = Path.GetFullPath(path); + if (Directory.Exists(resolvedPath)) + { + Directory.Delete(resolvedPath, recursive: true); + } + + Directory.CreateDirectory(resolvedPath); + } + + internal static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + internal static void WriteJson(string path, T value) + { + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(value, JsonOptions); + File.WriteAllText(path, json, new UTF8Encoding(false)); + } + + internal static string NormalizeRelativePath(string value) + { + return value.Replace('\\', '/').TrimStart('/'); + } + + internal static string ResolveArch(string platform) + { + if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase)) + { + return "x86"; + } + + if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase)) + { + return "arm64"; + } + + return "x64"; + } + + internal static bool ShouldIgnore(string relativePath) + { + var normalized = NormalizeRelativePath(relativePath.Trim()); + if (string.IsNullOrWhiteSpace(normalized)) + { + return true; + } + + return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase) + || normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("logs/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("cache/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("snapshots/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("snapshot/", StringComparison.OrdinalIgnoreCase); + } + + private static string? ResolveUnixFileMode(string path) + { + if (OperatingSystem.IsWindows()) + { + return null; + } + + try + { + var mode = File.GetUnixFileMode(path); + return Convert.ToString((int)mode, 8); + } + catch + { + return InferUnixFileMode(path); + } + } + + private static string? InferUnixFileMode(string path) + { + if (!LooksExecutable(path)) + { + return null; + } + + return "755"; + } + + private static bool LooksExecutable(string path) + { + try + { + using var stream = File.OpenRead(path); + Span header = stackalloc byte[4]; + var read = stream.Read(header); + if (read >= 4 && + header[0] == 0x7F && + header[1] == (byte)'E' && + header[2] == (byte)'L' && + header[3] == (byte)'F') + { + return true; + } + + if (read >= 2 && header[0] == (byte)'#' && header[1] == (byte)'!') + { + return true; + } + } + catch + { + return false; + } + + var extension = Path.GetExtension(path); + return string.IsNullOrWhiteSpace(extension) && + !OperatingSystem.IsWindows() && + Path.GetFileName(path).Contains("LanMountainDesktop", StringComparison.OrdinalIgnoreCase); + } + + internal sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size, string? UnixFileMode); +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs new file mode 100644 index 0000000..c86bb0d --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs @@ -0,0 +1,14 @@ +namespace Plonds.Core.Publishing; + +public sealed record PlondsDeltaBuildOptions( + string Platform, + string CurrentVersion, + string CurrentTag, + string CurrentPayloadZip, + string OutputRoot, + string PrivateKeyPath, + string Channel = "stable", + string? BaselineVersion = null, + string? BaselineTag = null, + string? BaselinePayloadZip = null, + bool IsFullPayload = false); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs new file mode 100644 index 0000000..84c4b09 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildResult.cs @@ -0,0 +1,13 @@ +namespace Plonds.Core.Publishing; + +public sealed record PlondsDeltaBuildResult( + string Platform, + string DistributionId, + string UpdateArchivePath, + string FileMapPath, + string FileMapSignaturePath, + string SummaryPath, + bool IsFullPayload, + string? BaselineTag, + string? BaselineVersion, + string TargetVersion); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs new file mode 100644 index 0000000..4164dcf --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs @@ -0,0 +1,228 @@ +using Plonds.Core.Security; +using Plonds.Shared.Models; + +namespace Plonds.Core.Publishing; + +public sealed class PlondsDeltaBuilder +{ + private readonly RsaFileSigner _signer = new(); + + public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip); + if (!File.Exists(currentPayloadZip)) + { + throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip); + } + + var baselinePayloadZip = string.IsNullOrWhiteSpace(options.BaselinePayloadZip) + ? null + : Path.GetFullPath(options.BaselinePayloadZip); + if (!string.IsNullOrWhiteSpace(baselinePayloadZip) && !File.Exists(baselinePayloadZip)) + { + throw new FileNotFoundException("Baseline payload zip not found.", baselinePayloadZip); + } + + var outputRoot = Path.GetFullPath(options.OutputRoot); + var workRoot = Path.Combine(outputRoot, "work", options.Platform); + var currentExtractRoot = Path.Combine(workRoot, "current"); + var baselineExtractRoot = Path.Combine(workRoot, "baseline"); + var objectsRoot = Path.Combine(workRoot, "objects"); + var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets"); + var summaryRoot = Path.Combine(outputRoot, "platform-summaries"); + + Directory.CreateDirectory(releaseAssetsRoot); + Directory.CreateDirectory(summaryRoot); + PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot); + + var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip); + if (useFullPayload) + { + PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot); + } + else + { + PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot); + } + + PayloadUtilities.EnsureCleanDirectory(objectsRoot); + + var previousManifest = useFullPayload + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : PayloadUtilities.ScanDirectory(baselineExtractRoot); + var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot); + var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot); + + var updateAssetName = $"update-{options.Platform}.zip"; + var fileMapAssetName = $"plonds-filemap-{options.Platform}.json"; + var fileMapSignatureAssetName = fileMapAssetName + ".sig"; + var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}"; + var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName); + var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName); + var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName); + + PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath); + + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["protocol"] = "PLONDS", + ["channel"] = options.Channel, + ["releaseTag"] = options.CurrentTag, + ["baselineTag"] = options.BaselineTag ?? string.Empty, + ["baselineVersion"] = options.BaselineVersion ?? "0.0.0", + ["targetVersion"] = options.CurrentVersion, + ["isFullPayload"] = useFullPayload ? "true" : "false" + }; + + var component = new ComponentDocument( + Name: "app", + Version: options.CurrentVersion, + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["component"] = "app", + ["mode"] = "file-object" + }, + Files: fileEntries); + + var fileMap = new FileMapDocument( + FormatVersion: "1.0", + DistributionId: distributionId, + FromVersion: options.BaselineVersion ?? "0.0.0", + ToVersion: options.CurrentVersion, + Version: options.CurrentVersion, + Platform: options.Platform, + Arch: PayloadUtilities.ResolveArch(options.Platform), + Channel: options.Channel, + GeneratedAt: DateTimeOffset.UtcNow, + Metadata: metadata, + Components: [component], + Files: fileEntries); + + PayloadUtilities.WriteJson(fileMapPath, fileMap); + _signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath); + + var summary = new PlondsReleasePlatformEntry( + Platform: options.Platform, + DistributionId: distributionId, + BaselineTag: options.BaselineTag, + BaselineVersion: options.BaselineVersion ?? "0.0.0", + TargetVersion: options.CurrentVersion, + IsFullPayload: useFullPayload, + FilesZipAsset: $"files-{options.Platform}.zip", + UpdateZipAsset: updateAssetName, + FileMapAsset: fileMapAssetName, + FileMapSignatureAsset: fileMapSignatureAssetName, + Sha256: PayloadUtilities.ComputeSha256(updateArchivePath)); + + var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json"); + PayloadUtilities.WriteJson(summaryPath, summary); + + return new PlondsDeltaBuildResult( + options.Platform, + distributionId, + updateArchivePath, + fileMapPath, + fileMapSignaturePath, + summaryPath, + useFullPayload, + options.BaselineTag, + options.BaselineVersion, + options.CurrentVersion); + } + + private static List BuildFileEntries( + IReadOnlyDictionary previousManifest, + IReadOnlyDictionary currentManifest, + string objectsRoot) + { + var result = new List(); + + foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + { + var current = currentManifest[path]; + if (previousManifest.TryGetValue(path, out var previous) && + string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase)) + { + result.Add(new FileEntryDocument( + Path: path, + Action: "reuse", + Sha256: current.Sha256, + Size: current.Size, + ObjectPath: null, + ObjectKey: null, + Metadata: null)); + continue; + } + + var action = previousManifest.ContainsKey(path) ? "replace" : "add"; + var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256); + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["mode"] = "file-object" + }; + if (!string.IsNullOrWhiteSpace(current.UnixFileMode)) + { + metadata["unixFileMode"] = current.UnixFileMode!; + } + + result.Add(new FileEntryDocument( + Path: path, + Action: action, + Sha256: current.Sha256, + Size: current.Size, + ObjectPath: objectPath, + ObjectKey: objectPath, + Metadata: metadata)); + } + + foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + { + if (currentManifest.ContainsKey(path)) + { + continue; + } + + result.Add(new FileEntryDocument( + Path: path, + Action: "delete", + Sha256: string.Empty, + Size: 0, + ObjectPath: null, + ObjectKey: null, + Metadata: null)); + } + + return result; + } + + private sealed record FileMapDocument( + string FormatVersion, + string DistributionId, + string FromVersion, + string ToVersion, + string Version, + string Platform, + string Arch, + string Channel, + DateTimeOffset GeneratedAt, + IReadOnlyDictionary Metadata, + IReadOnlyList Components, + IReadOnlyList Files); + + private sealed record ComponentDocument( + string Name, + string Version, + IReadOnlyDictionary? Metadata, + IReadOnlyList Files); + + private sealed record FileEntryDocument( + string Path, + string Action, + string Sha256, + long Size, + string? ObjectPath, + string? ObjectKey, + IReadOnlyDictionary? Metadata); +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs new file mode 100644 index 0000000..bb92d47 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexBuilder.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Plonds.Core.Security; +using Plonds.Shared.Models; + +namespace Plonds.Core.Publishing; + +public sealed class PlondsReleaseIndexBuilder +{ + private readonly RsaFileSigner _signer = new(); + + public string Build(PlondsReleaseIndexOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var summariesDirectory = Path.GetFullPath(options.PlatformSummariesDirectory); + if (!Directory.Exists(summariesDirectory)) + { + throw new DirectoryNotFoundException($"Platform summary directory not found: {summariesDirectory}"); + } + + var summaries = Directory + .EnumerateFiles(summariesDirectory, "platform-summary-*.json", SearchOption.TopDirectoryOnly) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .Select(ReadSummary) + .OrderBy(static entry => entry.Platform, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var manifest = new PlondsReleaseManifest( + FormatVersion: "1.0", + ReleaseTag: options.ReleaseTag, + Version: options.Version, + Channel: options.Channel, + GeneratedAt: DateTimeOffset.UtcNow, + Platforms: summaries); + + var outputRoot = Path.GetFullPath(options.OutputRoot); + var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets"); + Directory.CreateDirectory(releaseAssetsRoot); + + var manifestPath = Path.Combine(releaseAssetsRoot, "plonds.json"); + PayloadUtilities.WriteJson(manifestPath, manifest); + _signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig"); + return manifestPath; + } + + private static PlondsReleasePlatformEntry ReadSummary(string path) + { + var json = File.ReadAllText(path); + var summary = JsonSerializer.Deserialize(json, PayloadUtilities.JsonOptions); + if (summary is null) + { + throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}"); + } + + return summary; + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs new file mode 100644 index 0000000..e0c22ae --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsReleaseIndexOptions.cs @@ -0,0 +1,9 @@ +namespace Plonds.Core.Publishing; + +public sealed record PlondsReleaseIndexOptions( + string ReleaseTag, + string Version, + string Channel, + string PlatformSummariesDirectory, + string OutputRoot, + string PrivateKeyPath); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssAssetEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssAssetEntry.cs new file mode 100644 index 0000000..f1710f3 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssAssetEntry.cs @@ -0,0 +1,8 @@ +namespace Plonds.Shared.Models; + +public sealed record DdssAssetEntry( + string AssetId, + string FileName, + string Sha256, + long Size, + IReadOnlyList Mirrors); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssManifest.cs new file mode 100644 index 0000000..94ccc59 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssManifest.cs @@ -0,0 +1,7 @@ +namespace Plonds.Shared.Models; + +public sealed record DdssManifest( + string FormatVersion, + string ReleaseTag, + DateTimeOffset GeneratedAt, + IReadOnlyList Assets); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssMirrorEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssMirrorEntry.cs new file mode 100644 index 0000000..77af7f4 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/DdssMirrorEntry.cs @@ -0,0 +1,5 @@ +namespace Plonds.Shared.Models; + +public sealed record DdssMirrorEntry( + string Type, + string Url); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs new file mode 100644 index 0000000..9ad9359 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleaseManifest.cs @@ -0,0 +1,9 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsReleaseManifest( + string FormatVersion, + string ReleaseTag, + string Version, + string Channel, + DateTimeOffset GeneratedAt, + IReadOnlyList Platforms); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs new file mode 100644 index 0000000..4217c5c --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsReleasePlatformEntry.cs @@ -0,0 +1,14 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsReleasePlatformEntry( + string Platform, + string DistributionId, + string? BaselineTag, + string? BaselineVersion, + string TargetVersion, + bool IsFullPayload, + string FilesZipAsset, + string UpdateZipAsset, + string FileMapAsset, + string FileMapSignatureAsset, + string Sha256); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index 9b4c51c..05bd276 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -1,4 +1,4 @@ -using Plonds.Core.Publishing; +using Plonds.Core.Publishing; using Plonds.Core.Security; return await PlondsCli.RunAsync(args); @@ -29,6 +29,18 @@ internal static class PlondsCli case "publish": RunPublish(options); return Task.FromResult(0); + case "pack-payload": + RunPackPayload(options); + return Task.FromResult(0); + case "build-delta": + RunBuildDelta(options); + return Task.FromResult(0); + case "build-index": + RunBuildIndex(options); + return Task.FromResult(0); + case "build-ddss": + RunBuildDdss(options); + return Task.FromResult(0); default: Console.Error.WriteLine($"Unknown command: {command}"); PrintUsage(); @@ -101,6 +113,62 @@ internal static class PlondsCli } } + private static void RunPackPayload(Dictionary options) + { + var sourceDirectory = Require(options, "source-dir"); + var outputZip = Require(options, "output-zip"); + PayloadUtilities.CreatePayloadZip(sourceDirectory, outputZip); + Console.WriteLine(outputZip); + } + + private static void RunBuildDelta(Dictionary options) + { + var builder = new PlondsDeltaBuilder(); + var result = builder.Build(new PlondsDeltaBuildOptions( + Platform: Require(options, "platform"), + CurrentVersion: Require(options, "current-version"), + CurrentTag: Require(options, "current-tag"), + CurrentPayloadZip: Require(options, "current-zip"), + OutputRoot: Require(options, "output-dir"), + PrivateKeyPath: Require(options, "private-key"), + Channel: Get(options, "channel", "stable") ?? "stable", + BaselineVersion: Get(options, "baseline-version"), + BaselineTag: Get(options, "baseline-tag"), + BaselinePayloadZip: Get(options, "baseline-zip"), + IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload)); + + Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}"); + Console.WriteLine(result.FileMapPath); + } + + private static void RunBuildIndex(Dictionary options) + { + var builder = new PlondsReleaseIndexBuilder(); + var manifestPath = builder.Build(new PlondsReleaseIndexOptions( + ReleaseTag: Require(options, "release-tag"), + Version: Require(options, "version"), + Channel: Get(options, "channel", "stable") ?? "stable", + PlatformSummariesDirectory: Require(options, "platform-summaries-dir"), + OutputRoot: Require(options, "output-dir"), + PrivateKeyPath: Require(options, "private-key"))); + + Console.WriteLine(manifestPath); + } + + private static void RunBuildDdss(Dictionary options) + { + var builder = new DdssManifestBuilder(); + var manifestPath = builder.Build(new DdssBuildOptions( + ReleaseTag: Require(options, "release-tag"), + AssetsDirectory: Require(options, "assets-dir"), + OutputRoot: Require(options, "output-dir"), + PrivateKeyPath: Require(options, "private-key"), + Repository: Require(options, "repository"), + S3BaseUrl: Get(options, "s3-base-url"))); + + Console.WriteLine(manifestPath); + } + private static Dictionary ParseOptions(string[] args) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -142,8 +210,12 @@ internal static class PlondsCli private static void PrintUsage() { Console.WriteLine("PLONDS Tool"); - Console.WriteLine(" generate --current-version --current-dir --platform --output-dir [--previous-version ] [--previous-dir ]"); + Console.WriteLine(" pack-payload --source-dir --output-zip "); + Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload]"); + Console.WriteLine(" build-index --release-tag --version --platform-summaries-dir --output-dir --private-key [--channel ]"); + Console.WriteLine(" build-ddss --release-tag --assets-dir --output-dir --private-key --repository [--s3-base-url ]"); Console.WriteLine(" sign --manifest --private-key [--output ]"); + Console.WriteLine(" generate --current-version --current-dir --platform --output-dir [--previous-version ] [--previous-dir ]"); Console.WriteLine(" publish --version --app-artifacts-root --installer-artifacts-root --output-dir --private-key [--baseline-root ]"); } }