Rebuild release pipeline around PLONDS and DDSS

This commit is contained in:
lincube
2026-04-21 19:26:59 +08:00
parent 8568fdf16b
commit 8a75bc818a
21 changed files with 1730 additions and 1375 deletions

166
.github/workflows/ddss-publish.yml vendored Normal file
View File

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

235
.github/workflows/plonds-build.yml vendored Normal file
View File

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

View File

@@ -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
}
# é<>„剧ã<C2A7>šé<C5A1>™æˆ<C3A6>ç«·ç¼<C3A7>æ´ç<C2B4>?
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 <dev@example.com>"
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
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' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
@@ -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

View File

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

View File

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

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<UpdateCheckResult> 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<UpdateCheckResult> 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<LatestDescriptor?> GetLatestDescriptorAsync(
string latestUrl,
bool allowNoUpdateResponse,
CancellationToken cancellationToken)
{
try
{
var latestNode = await GetJsonNodeWithRetryAsync(
latestUrl,
PlondsCheckStage.Latest,
cancellationToken).ConfigureAwait(false);
return ParseLatestDescriptor(latestNode);
}
catch (PlondsRequestException ex)
when (allowNoUpdateResponse &&
ex.Stage == PlondsCheckStage.Latest &&
ex.StatusCode == HttpStatusCode.NoContent)
{
return null;
}
}
private async Task<DistributionDescriptor> ResolveDistributionAsync(
string endpoint,
string apiBasePath,
string latestUrl,
LatestDescriptor latest,
string channelId,
string platform,
CancellationToken cancellationToken)
{
var currentLatest = latest;
var hasRefreshedLatest = false;
while (true)
{
var distributionUrl = BuildApiUrl(
endpoint,
apiBasePath,
$"distributions/{Uri.EscapeDataString(currentLatest.DistributionId)}");
try
{
var distributionNode = await GetJsonNodeWithRetryAsync(
distributionUrl,
PlondsCheckStage.Distribution,
cancellationToken).ConfigureAwait(false);
if (TryCreateDistributionDescriptor(
distributionNode,
currentLatest,
channelId,
platform,
out var descriptor,
out var descriptorError))
{
return descriptor;
}
if (hasRefreshedLatest || descriptorError is null || !IsRecoverableDistributionError(descriptorError))
{
throw descriptorError ?? new PlondsRequestException(
PlondsCheckStage.PayloadParse,
"PLONDS distribution payload is incomplete.");
}
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution '{currentLatest.DistributionId}' is incomplete. Refreshing latest pointer once before failing.");
}
catch (PlondsRequestException ex) when (!hasRefreshedLatest && IsRecoverableDistributionError(ex))
{
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution fetch for '{currentLatest.DistributionId}' failed during {GetStageName(ex.Stage)}. Refreshing latest pointer once. Details: {ex.Message}");
}
hasRefreshedLatest = true;
currentLatest = await GetLatestDescriptorAsync(
latestUrl,
allowNoUpdateResponse: false,
cancellationToken).ConfigureAwait(false)
?? throw new PlondsRequestException(
PlondsCheckStage.Latest,
"PLONDS latest pointer disappeared while recovering the distribution payload.");
}
}
private async Task<JsonElement> GetJsonNodeWithRetryAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{
PlondsRequestException? lastError = null;
for (var attempt = 1; attempt <= MaxTransientRetryAttempts; attempt++)
{
try
{
return await GetJsonNodeAsync(url, stage, cancellationToken).ConfigureAwait(false);
}
catch (PlondsRequestException ex) when (attempt < MaxTransientRetryAttempts && ex.IsTransient)
{
lastError = ex;
AppLogger.Warn(
"PLONDS",
$"PLONDS {GetStageName(stage)} attempt {attempt}/{MaxTransientRetryAttempts} failed. Retrying shortly. Details: {ex.Message}");
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
}
return releaseResult with { ForceMode = false };
}
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
}
private async Task<JsonElement> GetJsonNodeAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
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<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
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<GitHubReleaseAsset> 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<GitHubReleaseAsset> Assets,
PlondsUpdatePayload Payload);
private sealed class PlondsRequestException : Exception
{
public PlondsRequestException(
PlondsCheckStage stage,
string message,
HttpStatusCode? statusCode = null,
bool isTransient = false,
Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
StatusCode = statusCode;
IsTransient = isTransient;
}
public PlondsCheckStage Stage { get; }
public HttpStatusCode? StatusCode { get; }
public bool IsTransient { get; }
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);
}
}

View File

@@ -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<PlondsDownloadEntry> downloadEntries;
try
IReadOnlyList<PlondsDownloadedObjectInfo> 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<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var totalSteps = expectedObjectCount + 2;
foreach (var entry in downloadEntries)
{
if (!objectTargets.Add(entry.ObjectHashHex))
IReadOnlyList<PlondsDownloadEntry> 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<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(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<IReadOnlyList<PlondsDownloadedObjectInfo>> EnsurePlondsArchiveObjectsAsync(
PlondsUpdatePayload payload,
string incomingDirectory,
string objectsDirectory,
string downloadSource,
int downloadThreads,
IProgress<double>? 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<double>(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<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
{
var entries = new List<PlondsDownloadEntry>();

View File

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

View File

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

View File

@@ -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<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
return manifest;
}
var resolvedRoot = Path.GetFullPath(root);
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
{
var relativePath = 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<T>(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<byte> 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);
}

View File

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

View File

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

View File

@@ -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<string, PayloadUtilities.FileFingerprint>(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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["protocol"] = "PLONDS",
["channel"] = options.Channel,
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = useFullPayload ? "true" : "false"
};
var component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(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<FileEntryDocument> BuildFileEntries(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string objectsRoot)
{
var result = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: null,
ObjectKey: null,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["mode"] = "file-object"
};
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
{
metadata["unixFileMode"] = current.UnixFileMode!;
}
result.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: objectPath,
ObjectKey: objectPath,
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<string, string> Metadata,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<FileEntryDocument> Files);
private sealed record ComponentDocument(
string Name,
string Version,
IReadOnlyDictionary<string, string>? Metadata,
IReadOnlyList<FileEntryDocument> Files);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string? ObjectPath,
string? ObjectKey,
IReadOnlyDictionary<string, string>? Metadata);
}

View File

@@ -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<PlondsReleasePlatformEntry>(json, PayloadUtilities.JsonOptions);
if (summary is null)
{
throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}");
}
return summary;
}
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace Plonds.Shared.Models;
public sealed record DdssAssetEntry(
string AssetId,
string FileName,
string Sha256,
long Size,
IReadOnlyList<DdssMirrorEntry> Mirrors);

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record DdssManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<DdssAssetEntry> Assets);

View File

@@ -0,0 +1,5 @@
namespace Plonds.Shared.Models;
public sealed record DdssMirrorEntry(
string Type,
string Url);

View File

@@ -0,0 +1,9 @@
namespace Plonds.Shared.Models;
public sealed record PlondsReleaseManifest(
string FormatVersion,
string ReleaseTag,
string Version,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);

View File

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

View File

@@ -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<string, string> 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<string, string> options)
{
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentTag: Require(options, "current-tag"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineVersion: Get(options, "baseline-version"),
BaselineTag: Get(options, "baseline-tag"),
BaselinePayloadZip: Get(options, "baseline-zip"),
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload));
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
Console.WriteLine(result.FileMapPath);
}
private static void RunBuildIndex(Dictionary<string, string> options)
{
var builder = new PlondsReleaseIndexBuilder();
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
ReleaseTag: Require(options, "release-tag"),
Version: Require(options, "version"),
Channel: Get(options, "channel", "stable") ?? "stable",
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key")));
Console.WriteLine(manifestPath);
}
private static void RunBuildDdss(Dictionary<string, string> 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<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -142,8 +210,12 @@ internal static class PlondsCli
private static void PrintUsage()
{
Console.WriteLine("PLONDS Tool");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
}
}