From 131043fe374972c77e74551bf9621976ec3a0a82 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 1 Jun 2026 16:53:23 +0800 Subject: [PATCH] =?UTF-8?q?changed.=E4=BF=AE=E6=94=B9=E4=BA=86PLONDS?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plonds-rollback.yml | 146 -------- .github/workflows/plonds-uploader.yml | 332 +++--------------- .../specs/plonds-comparator-redesign/spec.md | 42 ++- .../Publishing/PlondsPublishOptions.cs | 10 + .../Publishing/PlondsPublishResult.cs | 13 + .../Plonds.Core/Publishing/PlondsPublisher.cs | 141 ++++++++ .../Plonds.Core/Publishing/PlondsS3Client.cs | 260 ++++++++++++++ .../Publishing/PlondsS3ClientOptions.cs | 10 + .../Publishing/PlondsS3ObjectUpload.cs | 6 + .../Models/PlondsDownloadInfo.cs | 24 ++ .../Plonds.Shared/Models/PlondsManifest.cs | 3 +- .../src/Plonds.Tool/Program.cs | 59 +++- SECURITY_AUDIT_REPORT_2026-06-01.md | 329 +++++++++++++++++ analyze_commits.ps1 | 92 +++++ analyze_commits.py | 145 ++++++++ docs/auto_commit_md/README.md | 174 ++------- run_analysis.py | 177 ++++++++++ 17 files changed, 1370 insertions(+), 593 deletions(-) delete mode 100644 .github/workflows/plonds-rollback.yml create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishResult.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3Client.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ClientOptions.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ObjectUpload.cs create mode 100644 PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDownloadInfo.cs create mode 100644 SECURITY_AUDIT_REPORT_2026-06-01.md create mode 100644 analyze_commits.ps1 create mode 100644 analyze_commits.py create mode 100644 run_analysis.py diff --git a/.github/workflows/plonds-rollback.yml b/.github/workflows/plonds-rollback.yml deleted file mode 100644 index ae49749..0000000 --- a/.github/workflows/plonds-rollback.yml +++ /dev/null @@ -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" </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" diff --git a/.github/workflows/plonds-uploader.yml b/.github/workflows/plonds-uploader.yml index 396d169..f24cd2f 100644 --- a/.github/workflows/plonds-uploader.yml +++ b/.github/workflows/plonds-uploader.yml @@ -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 diff --git a/.trae/specs/plonds-comparator-redesign/spec.md b/.trae/specs/plonds-comparator-redesign/spec.md index 0885e09..10f9273 100644 --- a/.trae/specs/plonds-comparator-redesign/spec.md +++ b/.trae/specs/plonds-comparator-redesign/spec.md @@ -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` 和解压后的 `-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 目录布局: + //PLONDS.json + //changed.zip + //-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 ## 8. 不在本次改造范围内的事项 -- Publisher 工作流改造(后续单独设计) -- Rollback 工作流改造(后续单独设计) - 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计) - Launcher 侧客户端代码改造(后续单独设计) - Plonds.Api 项目处置(后续决定是否保留) -- `build-index`、`build-plonds`、`generate`、`publish`、`sign`、`pack-payload` 等 Tool 命令的清理(后续处理) +- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理) diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs new file mode 100644 index 0000000..6630e23 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishResult.cs new file mode 100644 index 0000000..7ca5fcf --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishResult.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs new file mode 100644 index 0000000..c3bcff4 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs @@ -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 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(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(); + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3Client.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3Client.cs new file mode 100644 index 0000000..2eec6ee --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3Client.cs @@ -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]; + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ClientOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ClientOptions.cs new file mode 100644 index 0000000..7d1ee4a --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ClientOptions.cs @@ -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 = ""); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ObjectUpload.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ObjectUpload.cs new file mode 100644 index 0000000..4d13f37 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsS3ObjectUpload.cs @@ -0,0 +1,6 @@ +namespace Plonds.Core.Publishing; + +public sealed record PlondsS3ObjectUpload( + string SourcePath, + string Key, + string ContentType); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDownloadInfo.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDownloadInfo.cs new file mode 100644 index 0000000..c017f5b --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDownloadInfo.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs index b2f70df..a6d58cb 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs @@ -11,4 +11,5 @@ public sealed record PlondsManifest( DateTimeOffset UpdatedAt, IReadOnlyDictionary FilesMap, IReadOnlyDictionary ChangedFilesMap, - IReadOnlyDictionary Checksums); + IReadOnlyDictionary Checksums, + PlondsDownloadInfo? Downloads = null); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index 3908913..3667b59 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -4,12 +4,12 @@ return await PlondsCli.RunAsync(args); internal static class PlondsCli { - public static Task RunAsync(string[] args) + public static async Task 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 RunPublishS3Async(Dictionary 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 ParseOptions(string[] args) { var result = new Dictionary(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 Source directory"); Console.WriteLine(" --output-zip Output zip path"); + Console.WriteLine(); + Console.WriteLine(" publish-s3 Publish PLONDS.json and changed.zip to S3"); + Console.WriteLine(" --release-tag GitHub release tag"); + Console.WriteLine(" --repository GitHub repository"); + Console.WriteLine(" --manifest PLONDS.json path"); + Console.WriteLine(" --changed-zip changed.zip path"); + Console.WriteLine(" --s3-endpoint S3-compatible endpoint"); + Console.WriteLine(" --s3-region S3 signing region"); + Console.WriteLine(" --s3-bucket S3 bucket"); + Console.WriteLine(" --s3-access-key S3 access key"); + Console.WriteLine(" --s3-secret-key S3 secret key"); + Console.WriteLine(" --s3-public-base-url Public URL prefix for uploaded keys"); + Console.WriteLine(" [--s3-public-base-key-prefix ] Key prefix already represented by public URL"); + Console.WriteLine(" [--s3-prefix ] Object key prefix (default: lanmountain/update/plonds)"); + Console.WriteLine(" [--work-dir ] Temporary publish work directory"); } } diff --git a/SECURITY_AUDIT_REPORT_2026-06-01.md b/SECURITY_AUDIT_REPORT_2026-06-01.md new file mode 100644 index 0000000..5b57e6a --- /dev/null +++ b/SECURITY_AUDIT_REPORT_2026-06-01.md @@ -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>(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(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)结论一致,代码库在安全性方面保持良好状态,未发现新增的中等及以上漏洞。 + +--- + +*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。* diff --git a/analyze_commits.ps1 b/analyze_commits.ps1 new file mode 100644 index 0000000..d25692e --- /dev/null +++ b/analyze_commits.ps1 @@ -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) 份报告。" diff --git a/analyze_commits.py b/analyze_commits.py new file mode 100644 index 0000000..544e779 --- /dev/null +++ b/analyze_commits.py @@ -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() diff --git a/docs/auto_commit_md/README.md b/docs/auto_commit_md/README.md index 7b7623e..2f8d129 100644 --- a/docs/auto_commit_md/README.md +++ b/docs/auto_commit_md/README.md @@ -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_.md` +## 使用方法 -- `YYYYMMDD` - 提交日期 -- `` - 提交哈希的前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 +python run_analysis.py ``` -## 文档内容结构 +### 方法二:使用 PowerShell 脚本 -每个 Markdown 文件包含以下部分: +```powershell +powershell -ExecutionPolicy Bypass -File analyze_commits.ps1 +``` -1. **基本信息表** - 提交哈希、作者、时间、父提交等 -2. **提交信息分析** - 对提交内容的解读 -3. **变更概览** - 查看详细变更的命令 -4. **提交类型** - 分类标记(版本发布、功能新增、Bug修复等) -5. **相关文档/链接** - 与提交相关的项目文档 +## 输出格式 -## 更新时间 +每个提交会生成一个 Markdown 文件,命名格式为:`YYYYMMDD_.md` -本文档集生成于:2026-05-07 +报告包含以下内容: +1. **基本信息** - 提交哈希、作者、时间等 +2. **提交信息** - 提交说明 +3. **变更统计** - 文件变更统计 +4. **详细变更** - 完整的 Git diff +5. **代码审查要点** - 人工审查提示 + +## 输出目录 + +所有报告保存在:`docs/auto_commit_md/` + +## 注意事项 + +- 确保已安装 Git 并配置好环境 +- 确保当前目录是 Git 仓库 +- 脚本仅分析当天(2026-06-01)的提交 diff --git a/run_analysis.py b/run_analysis.py new file mode 100644 index 0000000..f73ab07 --- /dev/null +++ b/run_analysis.py @@ -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())