mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Rebuild release pipeline around PLONDS and DDSS
This commit is contained in:
166
.github/workflows/ddss-publish.yml
vendored
Normal file
166
.github/workflows/ddss-publish.yml
vendored
Normal 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
235
.github/workflows/plonds-build.yml
vendored
Normal 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
|
||||||
647
.github/workflows/release.yml
vendored
647
.github/workflows/release.yml
vendored
@@ -15,23 +15,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
incremental_strategy:
|
|
||||||
description: 'Incremental strategy'
|
|
||||||
required: false
|
|
||||||
type: choice
|
|
||||||
default: release-payload
|
|
||||||
options:
|
|
||||||
- release-payload
|
|
||||||
- commit-range
|
|
||||||
publish_incremental_release:
|
|
||||||
description: 'Publish as incremental release'
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
baseline_ref:
|
|
||||||
description: 'Optional baseline tag/version/commit'
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
@@ -47,6 +30,8 @@ jobs:
|
|||||||
informational_version: ${{ steps.version.outputs.informational_version }}
|
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
|
release_channel: ${{ steps.version.outputs.release_channel }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository metadata
|
- name: Checkout repository metadata
|
||||||
@@ -60,6 +45,7 @@ jobs:
|
|||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
CHECKOUT_REF="${GITHUB_REF}"
|
CHECKOUT_REF="${GITHUB_REF}"
|
||||||
|
IS_PRERELEASE="false"
|
||||||
else
|
else
|
||||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||||
@@ -69,23 +55,40 @@ jobs:
|
|||||||
else
|
else
|
||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||||
CHECKOUT_REF="refs/tags/${TAG}"
|
CHECKOUT_REF="refs/tags/${TAG}"
|
||||||
else
|
else
|
||||||
CHECKOUT_REF="${GITHUB_SHA}"
|
CHECKOUT_REF="${GITHUB_SHA}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
|
||||||
|
IS_PRERELEASE="true"
|
||||||
|
else
|
||||||
|
IS_PRERELEASE="false"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||||
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
|
||||||
VERSION_PARTS+=("0")
|
VERSION_PARTS+=("0")
|
||||||
done
|
done
|
||||||
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||||
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
|
RELEASE_CHANNEL="preview"
|
||||||
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
|
else
|
||||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
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:
|
build-windows:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -133,9 +136,6 @@ jobs:
|
|||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
|
|
||||||
Write-Host "Publishing Launcher with AOT for Windows $arch..."
|
|
||||||
|
|
||||||
# AOT publish
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-o ./$launcherPublishDir `
|
-o ./$launcherPublishDir `
|
||||||
@@ -156,21 +156,6 @@ jobs:
|
|||||||
Write-Error "Launcher AOT publish failed"
|
Write-Error "Launcher AOT publish failed"
|
||||||
exit 1
|
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
|
shell: pwsh
|
||||||
|
|
||||||
- name: Publish Main App
|
- name: Publish Main App
|
||||||
@@ -208,9 +193,6 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Published to: $publishDir"
|
|
||||||
Write-Host "Self-contained: $selfContained"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Restructure for Launcher
|
- name: Restructure for Launcher
|
||||||
@@ -221,30 +203,18 @@ jobs:
|
|||||||
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
|
|
||||||
Write-Host "Restructuring for Launcher mode..."
|
|
||||||
Write-Host "Version: $version"
|
|
||||||
Write-Host "Publish dir: $publishDir"
|
|
||||||
|
|
||||||
$newStructure = "publish-launcher/windows-$arch"
|
$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
|
$appPath = Join-Path $newStructure $appDir
|
||||||
Move-Item -Path $publishDir -Destination $appPath -Force
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
$launcherSource = $launcherPublishDir
|
if (Test-Path $launcherPublishDir) {
|
||||||
if (Test-Path $launcherSource) {
|
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||||
Write-Host "Copying Launcher to root..."
|
|
||||||
Copy-Item -Path "$launcherSource\*" -Destination $newStructure -Recurse -Force
|
|
||||||
} else {
|
|
||||||
Write-Warning "Launcher publish dir not found: $launcherSource"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
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 $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||||
@@ -260,61 +230,27 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$version = "${{ needs.prepare.outputs.version }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
|
||||||
$suffix = "${{ matrix.suffix }}"
|
$suffix = "${{ matrix.suffix }}"
|
||||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
|
||||||
$outputDir = "build-installer"
|
$outputDir = "build-installer"
|
||||||
|
$installerScript = "LanMountainDesktop/packaging/windows/LanMountainDesktop.iss"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
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 = @(
|
$candidatePaths = @(
|
||||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||||
)
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
$isccPath = $candidatePaths | Select-Object -First 1
|
||||||
if (-not $isccPath) {
|
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."
|
Write-Error "Inno Setup compiler not found."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Found Inno Setup at: $isccPath"
|
|
||||||
|
|
||||||
Write-Host "Building installer for Windows $arch with version $version..."
|
|
||||||
|
|
||||||
$publishDir = (Resolve-Path $publishDir).Path
|
$publishDir = (Resolve-Path $publishDir).Path
|
||||||
$outputDir = (Resolve-Path $outputDir).Path
|
$outputDir = (Resolve-Path $outputDir).Path
|
||||||
$installerScript = (Resolve-Path $installerScript).Path
|
$installerScript = (Resolve-Path $installerScript).Path
|
||||||
@@ -329,8 +265,6 @@ jobs:
|
|||||||
$installerScript
|
$installerScript
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
|
||||||
|
|
||||||
& $isccPath @compileArgs
|
& $isccPath @compileArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||||
@@ -342,25 +276,53 @@ jobs:
|
|||||||
Write-Error "Failed to create installer"
|
Write-Error "Failed to create installer"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Successfully created: $($installerFile.Name)"
|
|
||||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload App Payload
|
- name: Package Payload Zip
|
||||||
uses: actions/upload-artifact@v4
|
run: |
|
||||||
with:
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
name: app-payload-windows-${{ matrix.arch }}
|
$arch = "${{ matrix.arch }}"
|
||||||
path: |
|
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||||
publish/windows-${{ matrix.arch }}/**
|
if (-not (Test-Path $payloadRoot)) {
|
||||||
if-no-files-found: error
|
Write-Error "Payload root not found: $payloadRoot"
|
||||||
retention-days: 30
|
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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: installer-windows-${{ matrix.arch }}
|
name: release-windows-${{ matrix.arch }}
|
||||||
path: build-installer/*.exe
|
path: |
|
||||||
|
release-assets/files-windows-${{ matrix.arch }}.zip
|
||||||
|
build-installer/*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
@@ -385,13 +347,10 @@ jobs:
|
|||||||
libx11-6 libxrandr2 libxinerama1 \
|
libx11-6 libxrandr2 libxinerama1 \
|
||||||
libxi6 libxcursor1 libxext6 \
|
libxi6 libxcursor1 libxext6 \
|
||||||
libxrender1 libxkbcommon-x11-0 \
|
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 libasound2t64 || sudo apt-get install -y libasound2
|
||||||
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
|
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
|
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
@@ -413,8 +372,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for Linux x64..."
|
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-linux-x64 \
|
-o ./publish/launcher-linux-x64 \
|
||||||
@@ -431,14 +388,6 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
@@ -464,25 +413,15 @@ jobs:
|
|||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
launcherDir="publish/launcher-linux-x64"
|
launcherDir="publish/launcher-linux-x64"
|
||||||
|
|
||||||
echo "Restructuring for Launcher mode..."
|
|
||||||
echo "Version: $version"
|
|
||||||
|
|
||||||
mkdir -p "$publishDir"
|
mkdir -p "$publishDir"
|
||||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||||
|
|
||||||
if [ -d "$launcherDir" ]; then
|
if [ -d "$launcherDir" ]; then
|
||||||
echo "Copying Launcher to root..."
|
|
||||||
cp -r "$launcherDir"/* "$publishDir/"
|
cp -r "$launcherDir"/* "$publishDir/"
|
||||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
else
|
|
||||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
touch "$publishDir/$appDir/.current"
|
touch "$publishDir/$appDir/.current"
|
||||||
|
|
||||||
echo "New directory structure:"
|
|
||||||
find "$publishDir" -maxdepth 2 | head -50
|
|
||||||
|
|
||||||
rm -rf "$launcherDir"
|
rm -rf "$launcherDir"
|
||||||
|
|
||||||
- name: Package as DEB
|
- name: Package as DEB
|
||||||
@@ -495,12 +434,6 @@ jobs:
|
|||||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
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/DEBIAN"
|
||||||
mkdir -p "build-deb/usr/local/bin"
|
mkdir -p "build-deb/usr/local/bin"
|
||||||
mkdir -p "build-deb/usr/share/applications"
|
mkdir -p "build-deb/usr/share/applications"
|
||||||
@@ -509,20 +442,6 @@ jobs:
|
|||||||
|
|
||||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
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 \
|
sed \
|
||||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
|
||||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||||
@@ -546,9 +465,9 @@ jobs:
|
|||||||
printf '%s\n' "Package: $package_name"
|
printf '%s\n' "Package: $package_name"
|
||||||
printf '%s\n' "Version: $package_version"
|
printf '%s\n' "Version: $package_version"
|
||||||
printf '%s\n' "Architecture: $arch"
|
printf '%s\n' "Architecture: $arch"
|
||||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
|
||||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
printf '%s\n' 'Description: LanMountain Desktop Application'
|
||||||
printf '%s\n' " A desktop application for LanMountain."
|
printf '%s\n' ' A desktop application for LanMountain.'
|
||||||
} > "build-deb/DEBIAN/control"
|
} > "build-deb/DEBIAN/control"
|
||||||
|
|
||||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
|
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 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||||
chmod 755 "build-deb/DEBIAN/postinst"
|
chmod 755 "build-deb/DEBIAN/postinst"
|
||||||
|
|
||||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
|
||||||
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
|
|
||||||
ls -lh "${package_name}_${package_version}_${arch}.deb"
|
- name: Package Payload Zip
|
||||||
else
|
run: |
|
||||||
echo "Error: Failed to build DEB package"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload App Payload
|
rm -rf "$stage_dir"
|
||||||
uses: actions/upload-artifact@v4
|
mkdir -p "$stage_dir" "$release_dir"
|
||||||
with:
|
rsync -a \
|
||||||
name: app-payload-linux-x64
|
--exclude '.current' \
|
||||||
path: |
|
--exclude '.partial' \
|
||||||
publish/linux-x64/**
|
--exclude '.destroy' \
|
||||||
if-no-files-found: error
|
"$payload_root/" "$stage_dir/"
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload Installer
|
(
|
||||||
|
cd "$stage_dir"
|
||||||
|
zip -qr "$release_dir/files-linux-x64.zip" .
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Upload Release Assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: installer-linux-x64
|
name: release-linux-x64
|
||||||
path: "*.deb"
|
path: |
|
||||||
|
release-assets/files-linux-x64.zip
|
||||||
|
*.deb
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
continue-on-error: true
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [x64, arm64]
|
arch: [x64, arm64]
|
||||||
name: Build_macOS_${{ matrix.arch }}
|
name: Build_macOS_${{ matrix.arch }}
|
||||||
@@ -620,8 +553,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||||
@@ -638,14 +569,6 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
- name: Publish Main App
|
||||||
run: |
|
run: |
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||||
@@ -664,7 +587,22 @@ jobs:
|
|||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_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
|
- name: Restructure and Package as DMG
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
version="${{ needs.prepare.outputs.version }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
arch="${{ matrix.arch }}"
|
arch="${{ matrix.arch }}"
|
||||||
@@ -673,41 +611,19 @@ jobs:
|
|||||||
launcherDir="publish/launcher-macos-$arch"
|
launcherDir="publish/launcher-macos-$arch"
|
||||||
appSourceDir="publish/macos-$arch-app"
|
appSourceDir="publish/macos-$arch-app"
|
||||||
|
|
||||||
echo "Restructuring for Launcher mode..."
|
|
||||||
echo "Version: $version"
|
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||||
|
|
||||||
appDir="app-$version"
|
appDir="app-$version"
|
||||||
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
|
||||||
|
|
||||||
if [ -d "$appSourceDir" ]; then
|
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
||||||
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
|
|
||||||
else
|
|
||||||
echo "Error: Main app source directory not found: $appSourceDir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$launcherDir" ]; then
|
if [ -d "$launcherDir" ]; then
|
||||||
echo "Copying Launcher to root..."
|
|
||||||
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
|
||||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||||
else
|
|
||||||
echo "Warning: Launcher publish dir not found: $launcherDir"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||||
|
|
||||||
mkdir -p "${app_name}.app/Contents/Resources"
|
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' '<?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">'
|
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
|
mkdir -p dmg-temp
|
||||||
cp -r "${app_name}.app" 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
|
- name: Upload Release Assets
|
||||||
echo "Successfully created: ${package_name}.dmg"
|
if: always()
|
||||||
ls -lh "${package_name}.dmg"
|
|
||||||
else
|
|
||||||
echo "Error: Failed to create DMG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf dmg-temp "${app_name}.app"
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: installer-macos-${{ matrix.arch }}
|
name: release-macos-${{ matrix.arch }}
|
||||||
path: "*.dmg"
|
path: |
|
||||||
if-no-files-found: error
|
release-assets/files-macos-${{ matrix.arch }}.zip
|
||||||
|
*.dmg
|
||||||
|
if-no-files-found: ignore
|
||||||
retention-days: 30
|
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:
|
github-release:
|
||||||
needs: [ prepare, build-windows, build-linux, build-macos, publish-plonds ]
|
needs: [prepare, build-windows, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download installer artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts/installers
|
path: release-files
|
||||||
pattern: installer-*
|
pattern: release-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Download PLONDS artifacts
|
- name: Validate release files
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts/plonds
|
|
||||||
pattern: plonds-assets
|
|
||||||
|
|
||||||
- name: List artifacts structure
|
|
||||||
run: |
|
run: |
|
||||||
echo "Artifact directory structure:"
|
echo "Release files:"
|
||||||
find artifacts -type f -o -type d | sort
|
find release-files -maxdepth 1 -type f -exec ls -lh {} \;
|
||||||
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'
|
|
||||||
|
|
||||||
- name: Flatten artifacts for release
|
if [ ! -f release-files/files-windows-x64.zip ] || [ ! -f release-files/files-windows-x86.zip ] || [ ! -f release-files/files-linux-x64.zip ]; then
|
||||||
run: |
|
echo "Required payload zips are missing."
|
||||||
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"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.tag }}
|
tag: ${{ needs.prepare.outputs.tag }}
|
||||||
name: ${{ needs.prepare.outputs.tag }}
|
name: ${{ needs.prepare.outputs.tag }}
|
||||||
commit: ${{ github.sha }}
|
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||||
artifacts: "release-files/**"
|
artifacts: 'release-files/**'
|
||||||
body: |
|
body: |
|
||||||
## Release ${{ needs.prepare.outputs.version }}
|
## Release ${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
### Windows
|
### Installers
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe** - 64-bit installer (includes .NET runtime)
|
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
|
||||||
- **LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe** - 32-bit installer (includes .NET runtime)
|
- `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.
|
### Payload Archives
|
||||||
|
- `files-windows-x64.zip`
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
- `files-windows-x86.zip`
|
||||||
|
- `files-linux-x64.zip`
|
||||||
### 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)
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-x64.dmg** - Intel processor
|
- macOS assets are best-effort and will not block the release.
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
|
||||||
|
|
||||||
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 }}
|
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
|
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ internal sealed class UpdateEngineService
|
|||||||
}
|
}
|
||||||
|
|
||||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||||
|
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +473,7 @@ internal sealed class UpdateEngineService
|
|||||||
var objectBytes = File.ReadAllBytes(objectPath);
|
var objectBytes = File.ReadAllBytes(objectPath);
|
||||||
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
|
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
|
||||||
File.WriteAllBytes(targetPath, restoredBytes);
|
File.WriteAllBytes(targetPath, restoredBytes);
|
||||||
|
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
|
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
|
||||||
@@ -914,6 +916,29 @@ internal sealed class UpdateEngineService
|
|||||||
metadata["component"] = componentName;
|
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
|
entry = new PlondsFileEntry
|
||||||
{
|
{
|
||||||
Path = path,
|
Path = path,
|
||||||
@@ -954,6 +979,31 @@ internal sealed class UpdateEngineService
|
|||||||
return true;
|
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)
|
private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||||
{
|
{
|
||||||
if (node.ValueKind == JsonValueKind.Object)
|
if (node.ValueKind == JsonValueKind.Object)
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ public sealed record PlondsUpdatePayload(
|
|||||||
string? FileMapJson,
|
string? FileMapJson,
|
||||||
string? FileMapSignature,
|
string? FileMapSignature,
|
||||||
string? FileMapJsonUrl,
|
string? FileMapJsonUrl,
|
||||||
string? FileMapSignatureUrl);
|
string? FileMapSignatureUrl,
|
||||||
|
string? UpdateArchiveUrl = null,
|
||||||
|
string? UpdateArchiveSha256 = null,
|
||||||
|
long? UpdateArchiveSizeBytes = null);
|
||||||
|
|
||||||
public sealed record UpdateDownloadResult(
|
public sealed record UpdateDownloadResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
@@ -159,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
var preferredAsset = isUpdateAvailable
|
var preferredAsset = isUpdateAvailable
|
||||||
? SelectPreferredInstallerAsset(release.Assets)
|
? SelectPreferredInstallerAsset(release.Assets)
|
||||||
: null;
|
: null;
|
||||||
|
var plondsPayload = isUpdateAvailable
|
||||||
|
? TryResolvePlondsPayload(release)
|
||||||
|
: null;
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
LatestVersionText: latestVersionText,
|
LatestVersionText: latestVersionText,
|
||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null);
|
ErrorMessage: null,
|
||||||
|
PlondsPayload: plondsPayload);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
|
var plondsPayload = TryResolvePlondsPayload(release);
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null,
|
||||||
ForceMode: true);
|
ForceMode: true,
|
||||||
|
PlondsPayload: plondsPayload);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -652,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
|
|
||||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -664,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
_ => "x64"
|
_ => "x64"
|
||||||
};
|
};
|
||||||
|
|
||||||
var ranked = assets
|
if (OperatingSystem.IsWindows())
|
||||||
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
|
{
|
||||||
.OrderByDescending(x => x.Score)
|
return assets
|
||||||
.ToList();
|
.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)
|
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
|
||||||
@@ -719,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
return score;
|
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)
|
private static bool TryParseVersion(string? value, out Version? version)
|
||||||
{
|
{
|
||||||
version = null;
|
version = null;
|
||||||
|
|||||||
@@ -1,52 +1,17 @@
|
|||||||
using System;
|
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.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thin PLONDS client used by the host app.
|
/// Release-backed PLONDS checker.
|
||||||
/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class PlondsReleaseUpdateService : IDisposable
|
public sealed class PlondsReleaseUpdateService : IDisposable
|
||||||
{
|
{
|
||||||
private const string DefaultApiBasePath = "/api/plonds/v1";
|
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||||
Version currentVersion,
|
Version currentVersion,
|
||||||
@@ -64,829 +29,52 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_githubReleaseUpdateService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||||
Version currentVersion,
|
Version currentVersion,
|
||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
bool isForce,
|
bool isForce,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
var releaseResult = isForce
|
||||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
var endpoint = ResolveEndpoint();
|
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
var latestVersionText = "-";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(endpoint))
|
if (!releaseResult.Success)
|
||||||
{
|
{
|
||||||
return new UpdateCheckResult(
|
return releaseResult;
|
||||||
Success: false,
|
|
||||||
IsUpdateAvailable: false,
|
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
|
||||||
LatestVersionText: latestVersionText,
|
|
||||||
Release: null,
|
|
||||||
PreferredAsset: null,
|
|
||||||
ErrorMessage: "PLONDS endpoint is not configured.",
|
|
||||||
ForceMode: isForce);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
if (!isForce && !releaseResult.IsUpdateAvailable)
|
||||||
{
|
{
|
||||||
var apiBasePath = ResolveApiBasePath();
|
return releaseResult with { ForceMode = false };
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
|
if (releaseResult.PlondsPayload is not null)
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
return releaseResult with { ForceMode = isForce };
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponseMessage response;
|
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
|
||||||
try
|
? "-"
|
||||||
{
|
: releaseResult.LatestVersionText;
|
||||||
response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
var message = releaseResult.Release is null
|
||||||
}
|
? "GitHub Release data is unavailable for PLONDS."
|
||||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
|
||||||
{
|
|
||||||
throw new PlondsRequestException(stage, "Request timed out.", isTransient: true, innerException: ex);
|
return new UpdateCheckResult(
|
||||||
}
|
Success: false,
|
||||||
catch (HttpRequestException ex)
|
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
|
||||||
{
|
CurrentVersionText: releaseResult.CurrentVersionText,
|
||||||
throw new PlondsRequestException(stage, $"Network error: {ex.Message}", isTransient: true, innerException: ex);
|
LatestVersionText: latestVersion,
|
||||||
}
|
Release: releaseResult.Release,
|
||||||
|
PreferredAsset: releaseResult.PreferredAsset,
|
||||||
using (response)
|
ErrorMessage: message,
|
||||||
{
|
ForceMode: isForce,
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
PlondsPayload: null);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
@@ -64,6 +65,7 @@ public sealed class UpdateWorkflowService
|
|||||||
private const string PlondsFileMapName = "plonds-filemap.json";
|
private const string PlondsFileMapName = "plonds-filemap.json";
|
||||||
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
|
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
|
||||||
private const string PlondsUpdateStateName = "plonds-update.json";
|
private const string PlondsUpdateStateName = "plonds-update.json";
|
||||||
|
private const string PlondsUpdateArchiveName = "plonds-update.zip";
|
||||||
|
|
||||||
private static readonly HttpClient PlondsHttpClient = new()
|
private static readonly HttpClient PlondsHttpClient = new()
|
||||||
{
|
{
|
||||||
@@ -302,47 +304,65 @@ public sealed class UpdateWorkflowService
|
|||||||
"filemap-download",
|
"filemap-download",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
IReadOnlyList<PlondsDownloadEntry> downloadEntries;
|
IReadOnlyList<PlondsDownloadedObjectInfo> objectResults;
|
||||||
try
|
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);
|
IReadOnlyList<PlondsDownloadEntry> downloadEntries;
|
||||||
}
|
try
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
completedItems++;
|
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
|
||||||
progress?.Report((double)completedItems / totalSteps);
|
}
|
||||||
continue;
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
var objectInfo = await EnsurePlondsObjectAsync(
|
if (downloadEntries.Count == 0)
|
||||||
entry,
|
{
|
||||||
objectsDir,
|
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
|
||||||
downloadThreads,
|
}
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
objectResults.Add(objectInfo);
|
var expectedObjectCount = downloadEntries.Count;
|
||||||
completedItems++;
|
var completedItems = 2;
|
||||||
progress?.Report((double)completedItems / totalSteps);
|
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(
|
var updateState = new PlondsUpdateState(
|
||||||
@@ -692,6 +712,91 @@ public sealed class UpdateWorkflowService
|
|||||||
lastError);
|
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)
|
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
|
||||||
{
|
{
|
||||||
var entries = new List<PlondsDownloadEntry>();
|
var entries = new List<PlondsDownloadEntry>();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Plonds.Core.Publishing;
|
||||||
|
|
||||||
|
public sealed record PlondsReleaseIndexOptions(
|
||||||
|
string ReleaseTag,
|
||||||
|
string Version,
|
||||||
|
string Channel,
|
||||||
|
string PlatformSummariesDirectory,
|
||||||
|
string OutputRoot,
|
||||||
|
string PrivateKeyPath);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record DdssAssetEntry(
|
||||||
|
string AssetId,
|
||||||
|
string FileName,
|
||||||
|
string Sha256,
|
||||||
|
long Size,
|
||||||
|
IReadOnlyList<DdssMirrorEntry> Mirrors);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record DdssManifest(
|
||||||
|
string FormatVersion,
|
||||||
|
string ReleaseTag,
|
||||||
|
DateTimeOffset GeneratedAt,
|
||||||
|
IReadOnlyList<DdssAssetEntry> Assets);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record DdssMirrorEntry(
|
||||||
|
string Type,
|
||||||
|
string Url);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Plonds.Core.Publishing;
|
using Plonds.Core.Publishing;
|
||||||
using Plonds.Core.Security;
|
using Plonds.Core.Security;
|
||||||
|
|
||||||
return await PlondsCli.RunAsync(args);
|
return await PlondsCli.RunAsync(args);
|
||||||
@@ -29,6 +29,18 @@ internal static class PlondsCli
|
|||||||
case "publish":
|
case "publish":
|
||||||
RunPublish(options);
|
RunPublish(options);
|
||||||
return Task.FromResult(0);
|
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:
|
default:
|
||||||
Console.Error.WriteLine($"Unknown command: {command}");
|
Console.Error.WriteLine($"Unknown command: {command}");
|
||||||
PrintUsage();
|
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)
|
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -142,8 +210,12 @@ internal static class PlondsCli
|
|||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Console.WriteLine("PLONDS Tool");
|
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(" 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>]");
|
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user