changed.修改了PLONDS上传逻辑

This commit is contained in:
lincube
2026-06-01 16:53:23 +08:00
parent a2ac302ee7
commit 131043fe37
17 changed files with 1370 additions and 593 deletions

View File

@@ -1,146 +0,0 @@
name: PLONDS Rollback
on:
workflow_dispatch:
inputs:
channel:
description: 'Target channel to rollback'
required: true
type: choice
default: stable
options:
- stable
- preview
target_tag:
description: 'Release tag to rollback to (e.g. v1.2.3)'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
rollback:
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
group: plonds-rollback-${{ github.event.inputs.channel }}
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve rollback context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
RAW_TAG="${{ github.event.inputs.target_tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
CHANNEL="${{ github.event.inputs.channel }}"
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
if [[ -z "$PUBLIC_BASE" ]]; then
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
fi
PUBLIC_BASE="${PUBLIC_BASE%/}"
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
- name: Validate rollback target assets
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 name in plonds.json plonds.json.sig; do
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$key" >/dev/null
done
- name: Build rollback pointer
shell: bash
run: |
set -euo pipefail
mkdir -p rollback-output
pointer_file="rollback-output/plonds-latest.json"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$pointer_file" <<EOF
{
"schemaVersion": 1,
"channel": "${RELEASE_CHANNEL}",
"releaseTag": "${RELEASE_TAG}",
"version": "${version}",
"updatedAt": "${updated_at}",
"manifest": {
"url": "${manifest_url}",
"signatureUrl": "${sig_url}"
}
}
EOF
jq -e . "$pointer_file" >/dev/null
- name: Publish rollback pointer
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
pointer_file="rollback-output/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Print rollback summary
shell: bash
run: |
set -euo pipefail
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json"

View File

@@ -1,7 +1,7 @@
name: PLONDS Publisher
name: PLONDS Publisher
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
@@ -19,6 +19,8 @@ on:
env:
DOTNET_VERSION: '10.0.x'
PLONDS_S3_PREFIX: lanmountain/update/plonds
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
jobs:
publish:
@@ -35,7 +37,7 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag and channel
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
@@ -53,22 +55,8 @@ jobs:
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
if [[ "$IS_PRERELEASE" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
if [[ -z "$PUBLIC_BASE" ]]; then
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
fi
PUBLIC_BASE="${PUBLIC_BASE%/}"
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -76,304 +64,64 @@ jobs:
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 }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; 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
- name: Download PLONDS 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
rm -rf plonds-assets
mkdir -p plonds-assets
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -D plonds-assets --clobber
test -f plonds-assets/changed.zip
test -f plonds-assets/PLONDS.json
jq -e . plonds-assets/PLONDS.json >/dev/null
- name: Prepare PLONDS static output
- name: Publish PLONDS assets to Rainyun S3
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
shell: bash
run: |
set -euo pipefail
rm -rf plonds-static
mkdir -p plonds-static
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
fi
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
unzip -q release-assets/plonds-static.zip -d plonds-static
fi
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
exit 1
fi
- 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" == "plonds.json" || "$name" == "plonds.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
REGION="${S3_REGION:-us-east-1}"
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
PUBLIC_BASE="${PUBLIC_BASE%/}"
- name: Upload PLONDS static output 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 --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
plonds-static/ \
"s3://$S3_BUCKET/lanmountain/update/" \
--only-show-errors
- name: Mirror installers 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
version="${RELEASE_TAG#v}"
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
platform=""
case "$name" in
*.exe)
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
;;
*.deb)
platform="linux-x64"
;;
*.dmg)
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
;;
esac
[[ -n "$platform" ]] || continue
key="lanmountain/update/installers/${platform}/${version}/${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
- name: Build PLONDS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-plonds \
publish-s3 \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir plonds-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
--manifest "$PWD/plonds-assets/PLONDS.json" \
--changed-zip "$PWD/plonds-assets/changed.zip" \
--work-dir "$PWD/plonds-publish-work" \
--s3-prefix "$PLONDS_S3_PREFIX" \
--s3-endpoint "$S3_ENDPOINT" \
--s3-region "$REGION" \
--s3-bucket "$S3_BUCKET" \
--s3-access-key "$S3_ACCESS_KEY" \
--s3-secret-key "$S3_SECRET_KEY" \
--s3-public-base-url "$PUBLIC_BASE" \
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX"
- name: Validate PLONDS asset references in 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
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
| sort -u)
jq -e '.downloads.github.changedZipUrl and .downloads.s3.changedFolderUrl' plonds-assets/PLONDS.json >/dev/null
if [[ -z "$keys" ]]; then
echo "No S3-backed asset URLs found in plonds.json"
exit 1
fi
while IFS= read -r key; do
[[ -n "$key" ]] || continue
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$key" >/dev/null
done <<< "$keys"
- name: Upload PLONDS manifest to release
- name: Upload enriched PLONDS manifest to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
- name: Upload PLONDS manifest to Rainyun S3 staging
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 plonds-output/plonds.json plonds-output/plonds.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
- name: Prepare PLONDS channel pointer
shell: bash
run: |
set -euo pipefail
pointer_file="plonds-output/plonds-latest.json"
cat > "$pointer_file" <<'JSON'
{
"schemaVersion": 1,
"channel": "__CHANNEL__",
"releaseTag": "__TAG__",
"version": "__VERSION__",
"updatedAt": "__UPDATED_AT__",
"manifest": {
"url": "__MANIFEST_URL__",
"signatureUrl": "__SIG_URL__"
}
}
JSON
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
jq -e . "$pointer_file" >/dev/null
- name: Atomically publish PLONDS channel pointer
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
pointer_file="plonds-output/plonds-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$staging_key" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Verify Rainyun S3 PLONDS output
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
mapfile -t required < <(
{
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
find plonds-static/repo/sha256 -type f | sort | head -n 1
} | sed '/^$/d'
)
if [[ "${#required[@]}" -lt 5 ]]; then
echo "Not enough PLONDS static files to verify."
exit 1
fi
for path in "${required[@]}"; do
rel="${path#plonds-static/}"
key="lanmountain/update/${rel}"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$key" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
done
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber

View File

@@ -250,7 +250,8 @@ public sealed record PlondsChangedFileEntry(
### 6.1 保留两个工作流
- **Comparator**`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
- **Publisher**`plonds-publisher.yml`,原 `plonds-uploader.yml`):发布器,负责上传到 S3 和生成 channel pointer
- **Publisher**`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip``PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
- **Rollback**:独立 rollback 工作流已废弃,不再维护
### 6.2 Comparator 改造后步骤
@@ -297,7 +298,38 @@ jobs:
→ 上传 artifact: plonds-run-metadata (tag.txt)
```
### 6.3 与当前步骤的差异
### 6.3 Publisher 改造后步骤
```yaml
# plonds-uploader.yml
触发: PLONDS Comparator completed / workflow_dispatch
jobs:
publish:
runs-on: ubuntu-latest
steps:
- Checkout
- 解析 release tag
- Setup .NET
- 构建 PLONDS Tool
- 从 GitHub Release 下载 changed.zip + PLONDS.json
- 调用 dotnet run Plonds.Tool -- publish-s3
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
→ S3 目录布局:
<prefix>/<version>/PLONDS.json
<prefix>/<version>/changed.zip
<prefix>/<version>/<version>-changed/**
→ 回写 PLONDS.json downloads 字段:
downloads.github.releaseUrl
downloads.github.manifestUrl
downloads.github.changedZipUrl
downloads.s3.manifestUrl
downloads.s3.changedZipUrl
downloads.s3.changedFolderUrl
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
```
### 6.4 与当前步骤的差异
| 当前步骤 | 改造后 |
|---------|--------|
@@ -307,6 +339,8 @@ jobs:
| 构建增量资产 (pwsh含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
| 独立 rollback workflow | ❌ 删除 |
## 7. 双模式差分生成
@@ -504,9 +538,7 @@ build-delta-from-commits --platform <platform>
## 8. 不在本次改造范围内的事项
- Publisher 工作流改造(后续单独设计)
- Rollback 工作流改造(后续单独设计)
- 宿主侧客户端代码改造PlondsUpdateApplier 等,后续单独设计)
- Launcher 侧客户端代码改造(后续单独设计)
- Plonds.Api 项目处置(后续决定是否保留)
- `build-index``build-plonds``generate``publish``sign``pack-payload` 等 Tool 命令的清理(后续处理)
- `build-index``generate``publish``sign` Tool 命令的清理(后续处理)

View File

@@ -0,0 +1,10 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishOptions(
string ReleaseTag,
string Repository,
string ManifestPath,
string ChangedZipPath,
string WorkDir,
string S3KeyPrefix,
PlondsS3ClientOptions S3);

View File

@@ -0,0 +1,13 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishResult(
string ReleaseTag,
string Version,
string VersionPrefix,
string ManifestKey,
string ManifestUrl,
string ChangedZipKey,
string ChangedZipUrl,
string ChangedFolderKey,
string ChangedFolderUrl,
int ChangedFileCount);

View File

@@ -0,0 +1,141 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsPublisher
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public async Task<PlondsPublishResult> PublishAsync(PlondsPublishOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
var releaseTag = Require(options.ReleaseTag, nameof(options.ReleaseTag));
var repository = Require(options.Repository, nameof(options.Repository));
var manifestPath = Path.GetFullPath(Require(options.ManifestPath, nameof(options.ManifestPath)));
var changedZipPath = Path.GetFullPath(Require(options.ChangedZipPath, nameof(options.ChangedZipPath)));
var workDir = Path.GetFullPath(Require(options.WorkDir, nameof(options.WorkDir)));
var version = releaseTag.TrimStart('v', 'V');
var prefix = NormalizePrefix(options.S3KeyPrefix);
var versionPrefix = $"{prefix}/{version}";
var changedFolderName = $"{version}-changed";
var changedExtractRoot = Path.Combine(workDir, changedFolderName);
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException("PLONDS manifest not found.", manifestPath);
}
if (!File.Exists(changedZipPath))
{
throw new FileNotFoundException("PLONDS changed.zip not found.", changedZipPath);
}
var manifest = LoadManifest(manifestPath);
PayloadUtilities.EnsureCleanDirectory(changedExtractRoot);
ZipFile.ExtractToDirectory(changedZipPath, changedExtractRoot, overwriteFiles: true);
var manifestKey = $"{versionPrefix}/PLONDS.json";
var changedZipKey = $"{versionPrefix}/changed.zip";
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
using var s3 = new PlondsS3Client(options.S3);
var changedFileCount = 0;
foreach (var filePath in Directory.EnumerateFiles(changedExtractRoot, "*", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(changedExtractRoot, filePath));
var objectKey = $"{changedFolderKey}/{relativePath}";
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filePath, objectKey, ResolveContentType(filePath)), cancellationToken).ConfigureAwait(false);
changedFileCount++;
}
await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
var updatedManifest = manifest with
{
Downloads = new PlondsDownloadInfo(
ReleaseTag: releaseTag,
GitHub: new PlondsGitHubDownloadInfo(
ReleaseUrl: $"https://github.com/{repository}/releases/tag/{releaseTag}",
ManifestUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/PLONDS.json",
ChangedZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/changed.zip"),
S3: new PlondsS3DownloadInfo(
Bucket: options.S3.Bucket,
Prefix: versionPrefix,
ManifestKey: manifestKey,
ManifestUrl: s3.BuildPublicUrl(manifestKey),
ChangedZipKey: changedZipKey,
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
ChangedFolderKey: changedFolderKey,
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey)))
};
File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false));
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, manifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
await s3.EnsureObjectExistsAsync(manifestKey, cancellationToken).ConfigureAwait(false);
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
return new PlondsPublishResult(
ReleaseTag: releaseTag,
Version: version,
VersionPrefix: versionPrefix,
ManifestKey: manifestKey,
ManifestUrl: s3.BuildPublicUrl(manifestKey),
ChangedZipKey: changedZipKey,
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
ChangedFolderKey: changedFolderKey,
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
ChangedFileCount: changedFileCount);
}
private static PlondsManifest LoadManifest(string manifestPath)
{
var json = File.ReadAllText(manifestPath);
return JsonSerializer.Deserialize<PlondsManifest>(json, JsonOptions)
?? throw new InvalidOperationException("PLONDS manifest is empty or invalid.");
}
private static string NormalizePrefix(string value)
{
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid S3 key prefix: {value}", nameof(value));
}
return normalized;
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path).ToLowerInvariant() switch
{
".json" => "application/json",
".zip" => "application/zip",
".dll" => "application/octet-stream",
".exe" => "application/octet-stream",
".pdb" => "application/octet-stream",
".deps" => "application/json",
".runtimeconfig" => "application/json",
".txt" => "text/plain",
".xml" => "application/xml",
_ => "application/octet-stream"
};
}
private static string Require(string value, string name)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"{name} is required.", name)
: value.Trim();
}
}

View File

@@ -0,0 +1,260 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
namespace Plonds.Core.Publishing;
public sealed class PlondsS3Client : IDisposable
{
private const string ServiceName = "s3";
private const string EmptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private readonly PlondsS3ClientOptions options;
private readonly HttpClient httpClient;
private readonly bool ownsHttpClient;
public PlondsS3Client(PlondsS3ClientOptions options, HttpClient? httpClient = null)
{
ArgumentNullException.ThrowIfNull(options);
this.options = options with
{
Endpoint = NormalizeEndpoint(options.Endpoint),
Region = Require(options.Region, nameof(options.Region)),
Bucket = Require(options.Bucket, nameof(options.Bucket)),
AccessKey = Require(options.AccessKey, nameof(options.AccessKey)),
SecretKey = Require(options.SecretKey, nameof(options.SecretKey)),
PublicBaseUrl = Require(options.PublicBaseUrl, nameof(options.PublicBaseUrl)).TrimEnd('/'),
PublicBaseKeyPrefix = NormalizeOptionalKeyPrefix(options.PublicBaseKeyPrefix)
};
this.httpClient = httpClient ?? new HttpClient();
ownsHttpClient = httpClient is null;
}
public async Task UploadFileAsync(PlondsS3ObjectUpload upload, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(upload);
var sourcePath = Path.GetFullPath(upload.SourcePath);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("S3 upload source file not found.", sourcePath);
}
var key = NormalizeKey(upload.Key);
var payloadHash = PayloadUtilities.ComputeSha256(sourcePath);
var contentLength = new FileInfo(sourcePath).Length;
var now = DateTimeOffset.UtcNow;
var requestUri = BuildObjectUri(key);
using var content = new StreamContent(File.OpenRead(sourcePath));
content.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(upload.ContentType)
? "application/octet-stream"
: upload.ContentType);
content.Headers.ContentLength = contentLength;
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
{
Content = content
};
SignRequest(request, key, payloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"S3 upload failed for {key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(body, 512)}");
}
}
public async Task EnsureObjectExistsAsync(string key, CancellationToken cancellationToken = default)
{
var normalizedKey = NormalizeKey(key);
var now = DateTimeOffset.UtcNow;
var requestUri = BuildObjectUri(normalizedKey);
using var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
SignRequest(request, normalizedKey, EmptyPayloadHash, now);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"S3 object verification failed for {normalizedKey}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
}
}
public string BuildPublicUrl(string key)
{
var normalizedKey = NormalizeKey(key);
if (!string.IsNullOrWhiteSpace(options.PublicBaseKeyPrefix) &&
(string.Equals(normalizedKey, options.PublicBaseKeyPrefix, StringComparison.OrdinalIgnoreCase) ||
normalizedKey.StartsWith($"{options.PublicBaseKeyPrefix}/", StringComparison.OrdinalIgnoreCase)))
{
normalizedKey = normalizedKey[options.PublicBaseKeyPrefix.Length..].TrimStart('/');
}
return $"{options.PublicBaseUrl}/{normalizedKey}";
}
public void Dispose()
{
if (ownsHttpClient)
{
httpClient.Dispose();
}
}
private void SignRequest(HttpRequestMessage request, string key, string payloadHash, DateTimeOffset now)
{
var amzDate = now.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
var dateStamp = now.UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
var credentialScope = $"{dateStamp}/{options.Region}/{ServiceName}/aws4_request";
var canonicalUri = BuildCanonicalUri(key);
var host = request.RequestUri?.IsDefaultPort == true
? request.RequestUri.Host
: request.RequestUri?.Authority;
if (string.IsNullOrWhiteSpace(host))
{
throw new InvalidOperationException("Cannot sign an S3 request without a host.");
}
request.Headers.Host = host;
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
var canonicalHeaders = new StringBuilder();
canonicalHeaders.Append("host:").Append(host).Append('\n');
canonicalHeaders.Append("x-amz-content-sha256:").Append(payloadHash).Append('\n');
canonicalHeaders.Append("x-amz-date:").Append(amzDate).Append('\n');
var signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalRequest = string.Join('\n',
[
request.Method.Method,
canonicalUri,
string.Empty,
canonicalHeaders.ToString(),
signedHeaders,
payloadHash
]);
var stringToSign = string.Join('\n',
[
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
Sha256Hex(canonicalRequest)
]);
var signingKey = GetSignatureKey(options.SecretKey, dateStamp, options.Region, ServiceName);
var signature = HmacSha256Hex(signingKey, stringToSign);
var authorization = $"AWS4-HMAC-SHA256 Credential={options.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
request.Headers.TryAddWithoutValidation("Authorization", authorization);
}
private Uri BuildObjectUri(string key)
{
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
var path = $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
var builder = new UriBuilder(options.Endpoint)
{
Path = path
};
return builder.Uri;
}
private string BuildCanonicalUri(string key)
{
var bucketPrefix = Uri.EscapeDataString(options.Bucket).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
return $"{options.Endpoint.AbsolutePath.TrimEnd('/')}/{bucketPrefix}/{BuildCanonicalKey(key)}";
}
private static string BuildCanonicalKey(string key)
{
return string.Join("/", NormalizeKey(key)
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
}
private static string NormalizeKey(string value)
{
var normalized = value.Replace('\\', '/').Trim('/');
if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid S3 object key: {value}", nameof(value));
}
return normalized;
}
private static string NormalizeOptionalKeyPrefix(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return NormalizeKey(value);
}
private static Uri NormalizeEndpoint(Uri endpoint)
{
if (!endpoint.IsAbsoluteUri)
{
throw new ArgumentException("S3 endpoint must be an absolute URI.", nameof(endpoint));
}
var builder = new UriBuilder(endpoint)
{
Path = endpoint.AbsolutePath.TrimEnd('/')
};
return builder.Uri;
}
private static string Require(string value, string name)
{
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"{name} is required.", name)
: value.Trim();
}
private static string Sha256Hex(string value)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
private static byte[] HmacSha256(byte[] key, string data)
{
return HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
}
private static string HmacSha256Hex(byte[] key, string data)
{
return Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant();
}
private static byte[] GetSignatureKey(string key, string dateStamp, string regionName, string serviceName)
{
var kDate = HmacSha256(Encoding.UTF8.GetBytes($"AWS4{key}"), dateStamp);
var kRegion = HmacSha256(kDate, regionName);
var kService = HmacSha256(kRegion, serviceName);
return HmacSha256(kService, "aws4_request");
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -0,0 +1,10 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ClientOptions(
Uri Endpoint,
string Region,
string Bucket,
string AccessKey,
string SecretKey,
string PublicBaseUrl,
string PublicBaseKeyPrefix = "");

View File

@@ -0,0 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsS3ObjectUpload(
string SourcePath,
string Key,
string ContentType);

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Plonds.Shared.Models;
public sealed record PlondsDownloadInfo(
string ReleaseTag,
[property: JsonPropertyName("github")]
PlondsGitHubDownloadInfo GitHub,
PlondsS3DownloadInfo S3);
public sealed record PlondsGitHubDownloadInfo(
string ReleaseUrl,
string ManifestUrl,
string ChangedZipUrl);
public sealed record PlondsS3DownloadInfo(
string Bucket,
string Prefix,
string ManifestKey,
string ManifestUrl,
string ChangedZipKey,
string ChangedZipUrl,
string ChangedFolderKey,
string ChangedFolderUrl);

View File

@@ -11,4 +11,5 @@ public sealed record PlondsManifest(
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);
IReadOnlyDictionary<string, string> Checksums,
PlondsDownloadInfo? Downloads = null);

View File

@@ -4,12 +4,12 @@ return await PlondsCli.RunAsync(args);
internal static class PlondsCli
{
public static Task<int> RunAsync(string[] args)
public static async Task<int> RunAsync(string[] args)
{
if (args.Length == 0)
{
PrintUsage();
return Task.FromResult(1);
return 1;
}
var command = args[0].Trim().ToLowerInvariant();
@@ -21,23 +21,25 @@ internal static class PlondsCli
{
case "build-delta":
RunBuildDelta(options);
return Task.FromResult(0);
return 0;
case "build-delta-from-commits":
RunBuildDeltaFromCommits(options);
return Task.FromResult(0);
return 0;
case "publish-s3":
return await RunPublishS3Async(options).ConfigureAwait(false);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
return 0;
default:
Console.Error.WriteLine($"Unknown command: {command}");
PrintUsage();
return Task.FromResult(1);
return 1;
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return Task.FromResult(1);
return 1;
}
}
@@ -93,6 +95,34 @@ internal static class PlondsCli
Console.WriteLine(outputZip);
}
private static async Task<int> RunPublishS3Async(Dictionary<string, string> options)
{
var publisher = new PlondsPublisher();
var result = await publisher.PublishAsync(new PlondsPublishOptions(
ReleaseTag: Require(options, "release-tag"),
Repository: Require(options, "repository"),
ManifestPath: Require(options, "manifest"),
ChangedZipPath: Require(options, "changed-zip"),
WorkDir: Get(options, "work-dir", "plonds-publish-work") ?? "plonds-publish-work",
S3KeyPrefix: Get(options, "s3-prefix", "lanmountain/update/plonds") ?? "lanmountain/update/plonds",
S3: new PlondsS3ClientOptions(
Endpoint: new Uri(Require(options, "s3-endpoint"), UriKind.Absolute),
Region: Get(options, "s3-region", "us-east-1") ?? "us-east-1",
Bucket: Require(options, "s3-bucket"),
AccessKey: Require(options, "s3-access-key"),
SecretKey: Require(options, "s3-secret-key"),
PublicBaseUrl: Require(options, "s3-public-base-url"),
PublicBaseKeyPrefix: Get(options, "s3-public-base-key-prefix", string.Empty) ?? string.Empty))).ConfigureAwait(false);
Console.WriteLine($"Published PLONDS release {result.ReleaseTag}:");
Console.WriteLine($" Prefix: {result.VersionPrefix}");
Console.WriteLine($" Manifest: {result.ManifestUrl}");
Console.WriteLine($" ChangedZip: {result.ChangedZipUrl}");
Console.WriteLine($" ChangedFolder: {result.ChangedFolderUrl}");
Console.WriteLine($" ChangedFileCount: {result.ChangedFileCount}");
return 0;
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -163,5 +193,20 @@ internal static class PlondsCli
Console.WriteLine(" pack-payload Pack a directory into a payload zip");
Console.WriteLine(" --source-dir <dir> Source directory");
Console.WriteLine(" --output-zip <file> Output zip path");
Console.WriteLine();
Console.WriteLine(" publish-s3 Publish PLONDS.json and changed.zip to S3");
Console.WriteLine(" --release-tag <tag> GitHub release tag");
Console.WriteLine(" --repository <owner/repo> GitHub repository");
Console.WriteLine(" --manifest <file> PLONDS.json path");
Console.WriteLine(" --changed-zip <file> changed.zip path");
Console.WriteLine(" --s3-endpoint <url> S3-compatible endpoint");
Console.WriteLine(" --s3-region <region> S3 signing region");
Console.WriteLine(" --s3-bucket <bucket> S3 bucket");
Console.WriteLine(" --s3-access-key <key> S3 access key");
Console.WriteLine(" --s3-secret-key <key> S3 secret key");
Console.WriteLine(" --s3-public-base-url <url> Public URL prefix for uploaded keys");
Console.WriteLine(" [--s3-public-base-key-prefix <prefix>] Key prefix already represented by public URL");
Console.WriteLine(" [--s3-prefix <prefix>] Object key prefix (default: lanmountain/update/plonds)");
Console.WriteLine(" [--work-dir <dir>] Temporary publish work directory");
}
}

View File

@@ -0,0 +1,329 @@
# LanMountainDesktop 安全审计报告
**审计日期**: 2026-06-01
**审计范围**: LanMountainDesktop 主仓库
**审计方法**: 静态代码分析 + 架构审查 + 威胁建模
---
## 执行摘要
本次安全审计系统性地检查了 LanMountainDesktop 代码库的高风险攻击面,包括认证与访问控制、注入向量、外部交互和敏感数据处理。
**审计结论**: **未发现中等或更高严重度的已确认漏洞。**
代码库展现了良好的安全设计原则,关键安全机制包括:
- 更新包采用 RSA 签名验证 + SHA-256/SHA-512 哈希校验
- 路径操作使用 `UpdatePathGuard` 进行标准化遍历防护
- 插件系统使用 AssemblyLoadContext 进行程序集隔离
- JSON 反序列化使用 System.Text.Json默认安全
- 遥测数据发送完全受用户同意控制
- Shell 执行针对用户主动操作URL 打开前经过验证
---
## 一、架构概述与信任边界
### 1.1 系统组件
| 组件 | 角色 | 信任级别 |
|------|------|----------|
| `LanMountainDesktop.Launcher/` | 启动器 - OOBE、Splash、版本选择 | 高(系统入口) |
| `LanMountainDesktop/` | 主桌面宿主 - UI、服务、插件运行时 | 高 |
| `LanMountainDesktop.AirAppRuntime/` | AirApp 独立容器 | 中 |
| 插件系统 | 用户安装的扩展代码 | 低(需沙箱) |
### 1.2 数据流边界
```
用户输入 → 新闻组件(RSS) → 解析后显示
用户安装插件 → SHA256验证 → AssemblyLoadContext隔离 → 加载执行
更新检查 → RSA签名验证 → SHA256校验 → 应用
遥测数据 → 用户同意检查 → PostHog SDK → 上报
```
---
## 二、详细审计结果
### 2.1 认证与访问控制
**审计范围**: OOBE 流程、隐私协议、会话管理、权限校验
| 项目 | 位置 | 风险评估 | 说明 |
|------|------|----------|------|
| OOBE 状态持久化 | `LanMountainDesktop.Launcher/Oobe/OobeStateService.cs` | ✅ 安全 | 原子写入JSON Schema 版本控制 |
| 隐私协议管理 | `PrivacyAgreementService.cs` | ✅ 安全 | 用户同意机制完善 |
| LaunchSource 验证 | `CommandContext.cs` | ✅ 安全 | 参数白名单验证 |
| 提权控制 | `ElevatedPluginInstallService.cs` | ✅ 安全 | 仅用于更新安装,需用户确认 |
**分析结论**: 本应用为本地桌面应用,无传统用户认证机制。隐私设置和遥测同意机制完善,用户可完全控制数据收集。
---
### 2.2 注入向量
#### 2.2.1 路径遍历防护
**验证代码** ([UpdatePathGuard.cs:L11-18](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdatePathGuard.cs#L11-L18)):
```csharp
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
{
var fullTarget = Path.GetFullPath(targetPath);
var fullRoot = Path.GetFullPath(rootPath);
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
}
}
```
✅ 使用 `OrdinalIgnoreCase` 防止大小写绕过,使用 `GetFullPath` 规范化路径。
#### 2.2.2 插件包文件名清理
**验证代码** ([PluginLoader.cs:L715-726](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginLoader.cs#L715-L726)):
```csharp
private static string SanitizeDirectoryName(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
}
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
}
```
✅ 插件目录名经过清理,避免路径注入。
#### 2.2.3 Shell 执行上下文
检查了 40+ 处 `Process.Start` 调用:
| 场景 | UseShellExecute | 路径来源 | 风险评估 |
|------|-----------------|----------|----------|
| 更新安装 | true (runas) | 固定路径,签名验证 | ✅ 安全 |
| URL 打开 | true | 用户配置的 RSS/新闻链接 | ✅ 有验证 |
| 快捷方式执行 | true | 用户配置的快捷方式 | ⚠️ 用户可控 |
| AirApp 启动 | false | 内部路径 | ✅ 安全 |
**URL 打开验证** ([IfengNewsWidget.axaml.cs:L534-554](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs#L534-L554)):
```csharp
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
return null;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return null;
return uri.ToString();
}
```
✅ URL 打开前验证协议必须为 http/https。
#### 2.2.4 JSON 反序列化
代码库广泛使用 `System.Text.Json` 进行反序列化:
```csharp
JsonSerializer.Deserialize<List<string>>(json); // PluginRuntimeService.cs:992
JsonSerializer.Deserialize(text, AppJsonContext.Default.Options); // 多个位置
```
✅ System.Text.Json 默认禁用类型元数据,可防止反序列化攻击。
**审计结论**: 注入向量风险评估为 **低**。路径操作有标准化防护Shell 执行主要针对用户主动操作且 URL 有验证。
---
### 2.3 外部交互
#### 2.3.1 更新系统安全机制
**RSA 签名验证** ([UpdateSignatureVerifier.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs)):
```csharp
using var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath));
var isValid = rsa.VerifyData(
payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
```
✅ 使用 PKCS#1 签名验证更新清单。
**文件哈希验证**:
- 下载文件经过 SHA-256 校验
- 插件包经过 SHA-256 + 大小双重校验
- 支持 SHA-512 增强校验
#### 2.3.2 插件市场安全
**插件包完整性验证** ([PluginMarketInstallService.cs:L248-282](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L248-L282)):
```csharp
// 大小校验
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
return verification failed;
// SHA-256 校验
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
return verification failed;
```
✅ 下载的插件包经过大小和哈希双重校验。
#### 2.3.3 HTTP 客户端配置
| 配置项 | 值 | 评估 |
|--------|-----|------|
| User-Agent | 设置完整 | ✅ |
| 超时 | 15-30 秒 | ✅ 合理 |
| HTTPS | 所有外部 API | ✅ |
| 响应验证 | 状态码检查 | ✅ |
#### 2.3.4 外部 RSS/新闻数据
新闻组件从以下来源获取数据:
- `imjuya.github.io/juya-ai-daily/rss.xml` (RSS)
- 凤凰新闻、百度/哔哩哔哩热搜等 Widget
**安全措施**:
- RSS 解析使用 XmlDocument/XDocument安全解析
- HTML 内容使用正则提取,纯文本展示
- 提取的链接必须为 http/https 协议
**审计结论**: 外部交互安全评估为 **安全**。所有更新和插件下载都有完整性验证。
---
### 2.4 敏感数据处理
#### 2.4.1 API 密钥分析
| 服务 | 位置 | 评估 |
|------|------|------|
| Xiaomi Weather API | `XiaomiWeatherService.cs:L13-36` | 低风险:公开天气数据 API |
| PostHog Analytics | `PostHogUsageTelemetryService.cs:L14` | 低风险:分析 SDK 公钥 |
**XiaomiWeatherService** ([XiaomiWeatherService.cs:L13-36](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L13-L36)):
```csharp
public sealed record XiaomiWeatherApiOptions
{
public string AppKey { get; init; } = "weather20151024";
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
}
```
⚠️ **说明**: 这些是天气数据 API 的公开凭证,用于获取公开天气数据,无用户敏感信息泄露风险。
#### 2.4.2 遥测服务
**遥测同意机制** ([PostHogUsageTelemetryService.cs:L71-100](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L71-L100)):
```csharp
public void RefreshEnabledState(bool forceSessionStart = false)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var enabled = snapshot.UploadAnonymousUsageData;
// 仅在用户同意时才发送遥测
}
```
✅ 遥测发送完全受 `UploadAnonymousUsageData` 设置控制。
**遥测收集的数据**:
- 安装 ID、应用版本、操作系统信息
- 桌面组件交互事件
- 设置页面导航事件
**不包括**: 用户文件内容、个人文档、密码、API 密钥等敏感信息。
#### 2.4.3 日志记录
检查了关键日志调用:
- 异常日志不包含敏感信息
- 命令行参数仅记录非敏感字段
- 遥测日志清晰标注是否启用
**审计结论**: 敏感数据处理评估为 **安全**。遥测受用户同意控制,无敏感信息日志记录。
---
### 2.5 架构安全评估
#### 2.5.1 插件运行时隔离
**当前设计**:
- 插件使用 `AssemblyLoadContext` 进行程序集隔离
- 共享类型白名单机制
- 插件运行在同一进程中
**缓解措施**:
- 插件 API 版本兼容性检查
- 插件清单验证 (`PluginManifest`)
- 签名验证(市场下载的插件)
- `.deps.json` 依赖验证
**风险说明**: 当前插件运行时属于进程内加载,这是已知的架构权衡。代码库已在 `.trae/specs/plugin-process-isolation/` 规划未来版本采用进程隔离方案。
#### 2.5.2 IPC 通信安全
外部 IPC 使用 `dotnetCampus.Ipc` 库:
- Named Pipe 传输
- `[IpcPublic]` 属性标记公开接口
- 请求路由白名单机制
- 服务注册需通过契约验证
**审计结论**: 架构设计安全考虑周全,进程隔离方案已在规划中。
---
## 三、安全最佳实践符合性
| 最佳实践 | 符合性 | 说明 |
|---------|-------|------|
| 输入验证 | ✅ | 参数解析、路径规范化、Schema 验证 |
| 输出编码 | ✅ | JSON 序列化使用 System.Text.Json |
| 加密标准 | ✅ | SHA-256/SHA-512, RSA 384-bit (PKCS#1) |
| 安全默认值 | ✅ | UseShellExecute=false 优先 |
| 错误处理 | ✅ | 异常捕获并记录,不泄露敏感信息 |
| 更新签名 | ✅ | RSA 签名验证更新包 |
| 插件隔离 | ⚠️ | AssemblyLoadContext 隔离,进程隔离规划中 |
| 密钥管理 | ⚠️ | 天气/遥测 API 密钥硬编码(低风险) |
---
## 四、非紧急改进建议
以下建议不属于安全漏洞,仅作为安全加固建议:
### 4.1 API 密钥管理
- 将天气 API 密钥移至配置系统
- 考虑使用服务端代理访问天气 API
- API 密钥轮换机制
### 4.2 插件进程隔离
- 加速推进 `plugin-process-isolation` 规划
- 评估 `dotnetCampus.Ipc` 进程间通信方案
### 4.3 安全清单
- 建立安全相关的持续集成检查
- 添加依赖漏洞扫描 (SAST)
- 考虑添加 HTTPS 证书固定
---
## 五、结论
### 审计状态: ✅ 通过
经过系统性审计,**未发现中等或更高严重度的已确认漏洞**。
### 代码质量评价
代码库展现了良好的安全意识:
1. **关键操作多层防护**: 更新安装、插件加载都有完整性校验
2. **路径操作标准化**: 使用 `UpdatePathGuard` 防止路径遍历
3. **外部数据验证完善**: 插件包 SHA-256 校验、RSA 签名验证
4. **用户隐私尊重**: 遥测完全受用户同意控制
5. **Shell 执行受控**: URL 打开前验证协议
### 与上次审计对比 (2026-05-31)
本次审计与上次报告2026-05-31结论一致代码库在安全性方面保持良好状态未发现新增的中等及以上漏洞。
---
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*

92
analyze_commits.ps1 Normal file
View File

@@ -0,0 +1,92 @@
# 分析当天的 Git 提交并生成 Markdown 报告
$todayStart = [DateTime]::Today
$todayEnd = [DateTime]::Now
$outputDir = "docs\auto_commit_md"
# 创建输出目录(如果不存在)
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
Write-Host "创建目录: $outputDir"
}
# 获取当天的所有提交
Write-Host "正在获取 $todayStart$todayEnd 之间的提交..."
$commits = git log --since="$($todayStart.ToString("yyyy-MM-dd HH:mm:ss"))" --until="$($todayEnd.ToString("yyyy-MM-dd HH:mm:ss"))" --pretty=format:"%H|%an|%ai|%s"
if ([string]::IsNullOrWhiteSpace($commits)) {
Write-Host "当天没有新的提交。"
exit 0
}
Write-Host "找到 $($commits.Split([Environment]::NewLine).Count) 个提交"
# 处理每个提交
$commitLines = $commits -split [Environment]::NewLine
foreach ($line in $commitLines) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$parts = $line -split '\|', 4
$hash = $parts[0]
$author = $parts[1]
$date = $parts[2]
$message = $parts[3]
$shortHash = $hash.Substring(0, 7)
$dateStr = [DateTime]::Parse($date).ToString("yyyyMMdd")
$outputFile = Join-Path $outputDir "${dateStr}_${shortHash}.md"
Write-Host "处理提交: $shortHash - $message"
# 获取详细的 diff
$diff = git show --stat --stat-width=120 --stat-name-width=80 $hash
$fullDiff = git show $hash
# 构建 Markdown 内容
$markdown = @"
# Git
##
| | |
|------|------|
| **** | $hash |
| **** | $shortHash |
| **** | $author |
| **** | $date |
##
$message
##
``````
$diff
``````
##
``````diff
$fullDiff
``````
##
>
-
- bug
-
-
---
*: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")*
"@
# 保存文件
$markdown | Out-File -FilePath $outputFile -Encoding UTF8
Write-Host "已保存: $outputFile"
}
Write-Host "`n完成!共生成 $($commitLines.Count) 份报告。"

145
analyze_commits.py Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分析当天的 Git 提交并生成 Markdown 报告
"""
import os
import subprocess
from datetime import datetime, date
def run_git_command(cmd):
"""运行 git 命令并返回输出"""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace'
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return -1, "", str(e)
def main():
# 设置日期范围
today_start = datetime.combine(date.today(), datetime.min.time())
today_end = datetime.now()
output_dir = "docs/auto_commit_md"
# 创建输出目录
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
print(f"创建目录: {output_dir}")
# 获取当天的所有提交
since_str = today_start.strftime("%Y-%m-%d %H:%M:%S")
until_str = today_end.strftime("%Y-%m-%d %H:%M:%S")
print(f"正在获取 {since_str}{until_str} 之间的提交...")
cmd = [
"git", "log",
f"--since={since_str}",
f"--until={until_str}",
"--pretty=format:%H|%an|%ai|%s"
]
code, stdout, stderr = run_git_command(cmd)
if code != 0:
print(f"错误: 获取提交失败: {stderr}")
return
if not stdout.strip():
print("当天没有新的提交。")
return
commits = stdout.strip().split('\n')
print(f"找到 {len(commits)} 个提交")
for line in commits:
line = line.strip()
if not line:
continue
parts = line.split('|', 3)
if len(parts) < 4:
continue
hash_full = parts[0]
author = parts[1]
commit_date = parts[2]
message = parts[3]
short_hash = hash_full[:7]
date_obj = datetime.fromisoformat(commit_date.replace('Z', '+00:00'))
date_str = date_obj.strftime("%Y%m%d")
output_file = os.path.join(output_dir, f"{date_str}_{short_hash}.md")
print(f"处理提交: {short_hash} - {message}")
# 获取统计信息
cmd_stat = ["git", "show", "--stat", "--stat-width=120", "--stat-name-width=80", hash_full]
_, stat_out, _ = run_git_command(cmd_stat)
# 获取完整 diff
cmd_diff = ["git", "show", hash_full]
_, diff_out, _ = run_git_command(cmd_diff)
# 构建 Markdown 内容
markdown = f"""# Git 提交分析报告
## 基本信息
| 项目 | 内容 |
|------|------|
| **提交哈希** | {hash_full} |
| **短哈希** | {short_hash} |
| **作者** | {author} |
| **提交时间** | {commit_date} |
## 提交信息
{message}
## 变更统计
```
{stat_out}
```
## 详细变更
```diff
{diff_out}
```
## 代码审查要点
> 本部分由系统自动生成,需要人工审查确认。
- 请检查代码变更是否符合项目规范
- 请检查是否有潜在的 bug 或安全问题
- 请检查测试是否覆盖了新代码
- 请检查文档是否需要更新
---
*报告生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}*
"""
# 保存文件
with open(output_file, 'w', encoding='utf-8') as f:
f.write(markdown)
print(f"已保存: {output_file}")
print(f"\n完成!共生成 {len(commits)} 份报告。")
if __name__ == "__main__":
main()

View File

@@ -1,156 +1,46 @@
# 提交历史分析文档
# Git 提交分析工具使用说明
本目录包含 LanMountainDesktop 项目的所有 Git 提交分析报告。
## 概述
## 文档统计
本工具用于分析当天2026-06-01的 Git 提交,并为每个提交生成结构化的 Markdown 分析报告。
| 统计项 | 数量 |
|--------|------|
| **总文档数** | **120 个** |
| 版本发布 (Release) | 11 个 |
| 功能新增 (Feature) | 45 个 |
| Bug 修复 (Bug Fix) | 32 个 |
| 文档更新 (Documentation) | 8 个 |
| CI/CD 相关 | 18 个 |
| 代码重构 (Refactoring) | 6 个 |
## 文件说明
## 文档命名规则
- `run_analysis.py` - 主分析脚本(推荐使用)
- `analyze_commits.py` - Python 版本分析脚本
- `analyze_commits.ps1` - PowerShell 版本分析脚本
每个文档的命名格式为:`YYYYMMDD_<commit_short_hash>.md`
## 使用方法
- `YYYYMMDD` - 提交日期
- `<commit_short_hash>` - 提交哈希的前7位
## 时间分布
| 月份 | 提交数量 |
|------|----------|
| 2025年4月 | 11 个 |
| 2025年5月 | 100 个 |
| 2025年6月 | 9 个 |
## 重要提交概览
### 版本发布
- [20250427_bd2313f](20250427_bd2313f.md) - 0.7.9.1
- [20250428_f84111e](20250428_f84111e.md) - 0.7.9.2
- [20250428_148e4c8](20250428_148e4c8.md) - 0.8.0
- [20250428_5804627](20250428_5804627.md) - 0.8.0.1
- [20250428_2dc729c](20250428_2dc729c.md) - 0.8.0.2
- [20250429_9045624](20250429_9045624.md) - 0.8.0.3
- [20250429_3b810fd](20250429_3b810fd.md) - 0.8.0.4
- [20250429_f50cfed](20250429_f50cfed.md) - 0.8.0.5
### 重要功能
- [20250501_964cef2](20250501_964cef2.md) - 通知系统,自习系统
- [20250501_88bd92e](20250501_88bd92e.md) - Hub组件支持双击打开图片三指翻页退出
- [20250502_44b87ba](20250502_44b87ba.md) - 桌面组件
- [20250502_1c3cc76](20250502_1c3cc76.md) - 状态栏文字组件,支持位置放置
- [20250503_0662565](20250503_0662565.md) - 文件管理组件跨平台支持
- [20250505_e1d5a0c](20250505_e1d5a0c.md) - 电源菜单
- [20250505_e69bbf8](20250505_e69bbf8.md) - 快捷方式组件
- [20250506_8c94253](20250506_8c94253.md) - 快捷方式组件透明问题修复
- [20250507_11130cf](20250507_11130cf.md) - 更新界面多标题修复
- [20250509_cb96180](20250509_cb96180.md) - 白板笔色自适应主题
- [20250510_4a89c23](20250510_4a89c23.md) - 便签组件
- [20250511_76d13ac](20250511_76d13ac.md) - 开发者调试工具
- [20250514_c2cc62b](20250514_c2cc62b.md) - 淡入淡出动画
- [20250514_03e32ee](20250514_03e32ee.md) - 网速显示组件
- [20250516_81ee19f](20250516_81ee19f.md) - AOT启动器
- [20250519_02547ee](20250519_02547ee.md) - 引入Velopack更新系统
- [20250520_a31ae3c](20250520_a31ae3c.md) - Penguin Logistics Online Network Distribution System
- [20250521_703ed7b](20250521_703ed7b.md) - 重构启动器启动、日志和主机解析
- [20250521_9224c9a](20250521_9224c9a.md) - 强化OOBE、启动源和权限流程
- [20250521_aa7c118](20250521_aa7c118.md) - 添加外部公共IPC主机/客户端和插件SDK
- [20250522_e20462a](20250522_e20462a.md) - 设置窗口独立化和任务栏感知
- [20250523_8b8c7d1](20250523_8b8c7d1.md) - 简化启动画面为淡入淡出
- [20250524_5b4b9f3](20250524_5b4b9f3.md) - OOBE重新设计、主题和数据位置支持
- [20250525_d310fc5](20250525_d310fc5.md) - Avalonia 12升级
- [20250528_9fb4137](20250528_9fb4137.md) - 迁移代码库到Avalonia 12 API
- [20250528_93d6d93](20250528_93d6d93.md) - 迁移到Avalonia 12和Plugin SDK v5
- [20250529_eb066b5](20250529_eb066b5.md) - 引入渲染模式和静态组件预览
- [20250530_0348324](20250530_0348324.md) - 添加LauncherPathResolver和重构数据路径
- [20250601_6a30bc6](20250601_6a30bc6.md) - 重构设置窗口UI和主题
- [20250601_49bbae2](20250601_49bbae2.md) - 使用Fluent Shell和搜索重新设计设置窗口
- [20250603_60e7f31](20250603_60e7f31.md) - 添加OOBE启动演示和设置合并
- [20250605_68ca532](20250605_68ca532.md) - 将白板持久化移动到文件存储
- [20250605_aa7e15d](20250605_aa7e15d.md) - 添加CODE_WIKI和更新本地化
- [20250605_84caca0](20250605_84caca0.md) - 数据设置页面和存储扫描器
### 样式统一
- [20250428_7a26848](20250428_7a26848.md) - CI.圆角
- [20250505_8583465](20250505_8583465.md) - 圆角统一
### Bug 修复
- [20250430_2272d35](20250430_2272d35.md) - 回退 0.8.0.41
- [20250501_ff01471](20250501_ff01471.md) - 修复智教 Hub 组件
- [20250502_021c7ff](20250502_021c7ff.md) - 修复智教Hub组件
- [20250502_00339f0](20250502_00339f0.md) - 修复Rinshub
- [20250506_66ae0b0](20250506_66ae0b0.md) - 课表组件日间模式字体颜色修复
- [20250508_cf4b8e2](20250508_cf4b8e2.md) - 央广网新闻组件第二行显示修复
- [20250508_e8ba847](20250508_e8ba847.md) - 融合桌面设置窗口修复
- [20250512_b933f3b](20250512_b933f3b.md) - 开发者调试工具设置持久化修复
- [20250512_ce5acf5](20250512_ce5acf5.md) - 快捷方式组件透明问题修复
- [20250515_e9ff590](20250515_e9ff590.md) - 可爱的我一直在修CI
- [20250516_6c526ff](20250516_6c526ff.md) - 修CILinux问题
- [20250518_9cf3a15](20250518_9cf3a15.md) - 修复启动器无法正常启动的问题
- [20250518_4f9feaf](20250518_4f9feaf.md) - 继续修CI
- [20250519_8e39ea8](20250519_8e39ea8.md) - GitHub Action工作流修复
- [20250519_6343164](20250519_6343164.md) - 修CI修融合桌面修启动器
- [20250528_f8073c2](20250528_f8073c2.md) - 修复合并产生的问题
### CI/CD 相关
- [20250515_59c4824](20250515_59c4824.md) - 启动器一定要能够启动
- [20250516_53ff98f](20250516_53ff98f.md) - Update build.yml
- [20250518_e8d2575](20250518_e8d2575.md) - 测试增量更新Velopack
- [20250519_f6a6f97](20250519_f6a6f97.md) - 迁移发布管道到签名文件映射
- [20250519_858612f](20250519_858612f.md) - 使可选S3上传步骤工作流解析安全
- [20250519_833c693](20250519_833c693.md) - 使增量包生成对空差异和Linux路径健壮
- [20250519_24b361b](20250519_24b361b.md) - 轮换启动器更新公钥
- [20250519_cddebbc](20250519_cddebbc.md) - 恢复稳定的启动器更新公钥
- [20250519_48ce93b](20250519_48ce93b.md) - 同步启动器公钥与更新签名密钥
- [20250519_1e6b61d](20250519_1e6b61d.md) - 规范化PEM行尾
- [20250519_c5ef418](20250519_c5ef418.md) - 轮换启动器公钥以匹配CI签名密钥
- [20250519_62e7d96](20250519_62e7d96.md) - 通过SPKI而非PEM文本比较签名密钥
- [20250519_fb21bcd](20250519_fb21bcd.md) - 重构更新后端到主机管理的PDC管道
- [20250520_81e0081](20250520_81e0081.md) - 修复发布工作流环境密钥冲突
- [20250520_8447910](20250520_8447910.md) - 放宽发布PDC预检查仅需要S3
- [20250520_8c58b1c](20250520_8c58b1c.md) - 为发布添加本地PDC模拟回退
- [20250520_e82c5d4](20250520_e82c5d4.md) - 为PDCC安装程序步骤设置GH_TOKEN
- [20250521_001a42a](20250521_001a42a.md) - 修复Windows安装程序脚本路径
- [20250521_631dc77](20250521_631dc77.md) - 规范化发布工件
- [20250521_8a75bc8](20250521_8a75bc8.md) - 围绕PLONDS和DDSS重建发布管道
### 文档更新
- [20250505_d30af21](20250505_d30af21.md) - 加入CHANGELOG
- [20250510_d62226f](20250510_d62226f.md) - 更新CHANGELOG
- [20250512_1b22e9d](20250512_1b22e9d.md) - 新增插件开发文档
## 查看完整提交历史
如需查看完整的提交历史,请使用以下命令:
### 方法一:使用 Python 脚本(推荐)
```bash
# 查看所有提交
git log --oneline
# 查看详细提交信息
git log --pretty=format:"%H|%an|%ad|%s" --date=format:"%Y-%m-%d %H:%M:%S"
# 查看特定提交的详细变更
git show <commit_hash>
python run_analysis.py
```
## 文档内容结构
### 方法二:使用 PowerShell 脚本
每个 Markdown 文件包含以下部分:
```powershell
powershell -ExecutionPolicy Bypass -File analyze_commits.ps1
```
1. **基本信息表** - 提交哈希、作者、时间、父提交等
2. **提交信息分析** - 对提交内容的解读
3. **变更概览** - 查看详细变更的命令
4. **提交类型** - 分类标记版本发布、功能新增、Bug修复等
5. **相关文档/链接** - 与提交相关的项目文档
## 输出格式
## 更新时间
每个提交会生成一个 Markdown 文件,命名格式为:`YYYYMMDD_<commit_short_hash>.md`
本文档集生成于2026-05-07
报告包含以下内容:
1. **基本信息** - 提交哈希、作者、时间等
2. **提交信息** - 提交说明
3. **变更统计** - 文件变更统计
4. **详细变更** - 完整的 Git diff
5. **代码审查要点** - 人工审查提示
## 输出目录
所有报告保存在:`docs/auto_commit_md/`
## 注意事项
- 确保已安装 Git 并配置好环境
- 确保当前目录是 Git 仓库
- 脚本仅分析当天2026-06-01的提交

177
run_analysis.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
运行 Git 提交分析
"""
import os
import sys
import subprocess
from datetime import datetime, date
def run_command(cmd, shell=False):
"""运行命令并返回结果"""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
shell=shell,
timeout=30
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return -2, "", "命令超时"
except Exception as e:
return -1, "", str(e)
def main():
print("=" * 60)
print("Git 提交分析工具")
print("=" * 60)
# 创建输出目录
output_dir = "docs/auto_commit_md"
try:
os.makedirs(output_dir, exist_ok=True)
print(f"输出目录: {os.path.abspath(output_dir)}")
except Exception as e:
print(f"创建目录失败: {e}")
return 1
# 检查是否是 Git 仓库
code, _, stderr = run_command(["git", "rev-parse", "--is-inside-work-tree"])
if code != 0:
print(f"错误: 不是 Git 仓库: {stderr}")
return 1
# 设置日期范围
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.now()
since_str = today_start.strftime("%Y-%m-%d %H:%M:%S")
until_str = today_end.strftime("%Y-%m-%d %H:%M:%S")
print(f"\n分析日期范围: {since_str}{until_str}")
# 获取提交列表
print("\n正在获取提交列表...")
cmd = [
"git", "log",
f"--since={since_str}",
f"--until={until_str}",
"--pretty=format:%H|%an|%ai|%s",
"--no-merges"
]
code, stdout, stderr = run_command(cmd)
if code != 0:
print(f"获取提交失败: {stderr}")
return 1
if not stdout.strip():
print("当天没有新的提交。")
return 0
commits = [line.strip() for line in stdout.strip().split('\n') if line.strip()]
print(f"找到 {len(commits)} 个提交\n")
# 处理每个提交
for i, line in enumerate(commits, 1):
parts = line.split('|', 3)
if len(parts) < 4:
continue
hash_full = parts[0]
author = parts[1]
commit_date = parts[2]
message = parts[3]
short_hash = hash_full[:7]
try:
date_obj = datetime.fromisoformat(commit_date.replace('Z', '+00:00'))
date_str = date_obj.strftime("%Y%m%d")
except:
date_str = today.strftime("%Y%m%d")
output_file = os.path.join(output_dir, f"{date_str}_{short_hash}.md")
print(f"[{i}/{len(commits)}] 处理: {short_hash}")
print(f" 作者: {author}")
print(f" 信息: {message[:60]}{'...' if len(message) > 60 else ''}")
# 获取统计信息
cmd_stat = ["git", "show", "--stat", "--stat-width=120", "--stat-name-width=80", hash_full]
_, stat_out, _ = run_command(cmd_stat)
# 获取完整 diff
cmd_diff = ["git", "show", hash_full]
_, diff_out, _ = run_command(cmd_diff)
# 构建 Markdown
markdown = f"""# Git 提交分析报告
## 基本信息
| 项目 | 内容 |
|------|------|
| **提交哈希** | {hash_full} |
| **短哈希** | {short_hash} |
| **作者** | {author} |
| **提交时间** | {commit_date} |
## 提交信息
{message}
## 变更统计
```
{stat_out}
```
## 详细变更
```diff
{diff_out}
```
## 代码审查要点
> 本部分由系统自动生成,需要人工审查确认。
- 请检查代码变更是否符合项目规范
- 请检查是否有潜在的 bug 或安全问题
- 请检查测试是否覆盖了新代码
- 请检查文档是否需要更新
---
*报告生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}*
"""
# 保存文件
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(markdown)
print(f" 已保存: {os.path.basename(output_file)}")
except Exception as e:
print(f" 保存失败: {e}")
print()
print("=" * 60)
print(f"完成!共生成 {len(commits)} 份报告")
print(f"报告位置: {os.path.abspath(output_dir)}")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())