mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
changed.修改了PLONDS上传逻辑
This commit is contained in:
146
.github/workflows/plonds-rollback.yml
vendored
146
.github/workflows/plonds-rollback.yml
vendored
@@ -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"
|
||||
332
.github/workflows/plonds-uploader.yml
vendored
332
.github/workflows/plonds-uploader.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 命令的清理(后续处理)
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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 = "");
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Plonds.Core.Publishing;
|
||||
|
||||
public sealed record PlondsS3ObjectUpload(
|
||||
string SourcePath,
|
||||
string Key,
|
||||
string ContentType);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
329
SECURITY_AUDIT_REPORT_2026-06-01.md
Normal file
329
SECURITY_AUDIT_REPORT_2026-06-01.md
Normal 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
92
analyze_commits.ps1
Normal 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
145
analyze_commits.py
Normal 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()
|
||||
@@ -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) - 修CI,Linux问题
|
||||
- [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
177
run_analysis.py
Normal 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())
|
||||
Reference in New Issue
Block a user