diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 924250a..61a600d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,271 +1,271 @@ -name: 程式碼建置檢查 - -on: - pull_request: - branches: - # 應用程式採用 main 保持可發布、dev 每輪先對齊 main 再提交 PR 的守門流程。 - - main - -concurrency: - # 同一分支或 PR 只保留最新一次執行,降低重複建置浪費。 - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - outputs: - code: ${{ steps.filter.outputs.code }} - ui: ${{ steps.filter.outputs.ui }} - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - name: 判斷是否涉及 UI 冒煙測試範圍 - id: filter - uses: dorny/paths-filter@v4 - with: - filters: | - code: - - 'src/**' - - 'tests/**' - - 'tools/**' - - 'eng/nuget/**' - - '.github/workflows/**' - - 'NuGet.config' - - 'global.json' - - '*.sln' - - '*.slnx' - - '**/*.csproj' - - '**/*.props' - - '**/*.targets' - ui: - - 'src/InputBox/MainForm*.cs' - - 'src/InputBox/MainForm.resx' - - 'src/InputBox/Core/Controls/**' - - 'src/InputBox/Resources/**' - - 'tests/InputBox.Tests/MainFormUiSmokeTests.cs' - - 'tests/InputBox.Tests/UiSmokeTestRequirements.cs' - - build-check: - if: needs.changes.outputs.code == 'true' - needs: changes - runs-on: windows-latest - timeout-minutes: 15 - permissions: - # 只需要讀取原始碼進行建置即可。 - contents: read - - # 定義環境變數,方便統一管理。 - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - # xUnit v3 MTP 的 coverage 輸出在 bin 輸出目錄的 TestResults 子目錄下。 - COVERAGE_GLOB: tests/**/coverage.cobertura.xml - REPORT_DIR: CoverageReport - # 行覆蓋率最低門檻(%)。低於此值時 CI 強制失敗。 - # WinForms UI 層雖已加入少量 FlaUI 冒煙測試,但大部分互動與硬體流程仍不納入 coverage 基線, - # 因此整體行覆蓋率仍維持在低檔,這裡僅作為基本迴歸守衛。 - # 若移除已測邏輯或測試檔,CI 應能即時失敗提醒。 - COVERAGE_THRESHOLD: '2' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - # 使用 GitHub Actions 語法來讀取 env 變數。 - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - # 啟用快取功能,加速 NuGet 套件還原。 - cache: true - # 同時涵蓋主專案、測試專案與工具清單,確保 NuGet 套件快取完整。 - cache-dependency-path: | - **/*.csproj - NuGet.config - eng/nuget/*.sha256 - .config/dotnet-tools.json - - - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" - $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" - - if (-not (Test-Path $packagePath)) { - Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" - } - - if (-not (Test-Path $checksumPath)) { - Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" - } - - $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() - $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { - Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" - } - - $expectedCommit = "72697db594b8f1863bd3fbf5db75ff69b40e3364" - $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" - if (Test-Path $inspectDir) { - Remove-Item -LiteralPath $inspectDir -Recurse -Force - } - New-Item -ItemType Directory -Path $inspectDir | Out-Null - Expand-Archive -Path $packagePath -DestinationPath $inspectDir -Force - $nuspecPath = Get-ChildItem -LiteralPath $inspectDir -Filter "*.nuspec" -File | Select-Object -First 1 - if ($null -eq $nuspecPath) { - Write-Error "InputWeave.GameInput 套件缺少 nuspec。" - } - $nuspecText = Get-Content -LiteralPath $nuspecPath.FullName -Raw - if ($nuspecText -notmatch "commit=`"$expectedCommit`"") { - Write-Error "InputWeave.GameInput 套件封裝來源 commit 不符;必須為 $expectedCommit。" - } - - Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" - Write-Host "InputWeave.GameInput 套件封裝來源 commit 驗證通過:$expectedCommit" - - - name: 還原主專案 NuGet 套件 - run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate - - - name: 建置應用程式與測試專案(Build App + Tests) - working-directory: ./tests/InputBox.Tests - # 測試專案會連同主專案相依一起建置,避免重複編譯浪費資源。 - run: dotnet build InputBox.Tests.csproj -c Release --no-incremental - - - name: 上傳建置產出(供 ui-smoke 重用) - if: needs.changes.outputs.ui == 'true' - uses: actions/upload-artifact@v7 - with: - name: build-output - path: | - tests/InputBox.Tests/bin/Release/ - src/InputBox/bin/Release/ - retention-days: 1 - if-no-files-found: error - - - name: 執行非 UI 單元測試並收集覆蓋率(Test + Coverage) - working-directory: ./tests/InputBox.Tests - # xUnit v3 + MTP 原生模式(global.json test.runner=Microsoft.Testing.Platform)。 - # UI 冒煙測試使用 FlaUI,與一般邏輯測試分流,避免桌面自動化影響 coverage 基線穩定度。 - # --no-build 重用上一步的建置產出;coverage 輸出至 bin/Release 目錄下的 TestResults 子目錄。 - run: | - dotnet test --project InputBox.Tests.csproj -c Release --no-build ` - --filter-not-trait "Category=UI" ` - --coverage ` - --coverage-output-format cobertura ` - --coverage-output coverage.cobertura.xml - - - name: 產生覆蓋率報告(ReportGenerator) - run: | - dotnet tool restore - dotnet tool run reportgenerator ` - "-reports:${{ env.COVERAGE_GLOB }}" ` - "-targetdir:${{ env.REPORT_DIR }}" ` - "-reporttypes:Html;Cobertura;MarkdownSummaryGithub" ` - "-filefilters:-*\obj\*" - - - name: 寫入覆蓋率摘要至 GitHub Step Summary - run: | - Get-Content -Path "${{ env.REPORT_DIR }}/SummaryGithub.md" | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - - name: 上傳覆蓋率報告(Artifact) - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: ${{ env.REPORT_DIR }} - if-no-files-found: error - retention-days: 7 - - - name: 覆蓋率門檻檢查(Coverage Threshold Gate) - run: | - $xml = [xml](Get-Content -Path "${{ env.REPORT_DIR }}/Cobertura.xml" -Encoding UTF8) - $lineRate = [double]$xml.coverage.'line-rate' - $pct = [Math]::Round($lineRate * 100, 1) - Write-Host "目前行覆蓋率:$pct%(最低門檻:${{ env.COVERAGE_THRESHOLD }}%)" - if ($pct -lt [double]"${{ env.COVERAGE_THRESHOLD }}") { - Write-Host "##[error]覆蓋率 $pct% 低於最低門檻 ${{ env.COVERAGE_THRESHOLD }}%,請補充測試後再提交。" - exit 1 - } - Write-Host "覆蓋率檢查通過。" - - - name: 建置與測試成功通知 - if: success() - run: Write-Host "建置與測試全部通過!程式碼狀態良好。" - - ui-smoke: - if: needs.changes.outputs.ui == 'true' - runs-on: windows-latest - timeout-minutes: 8 - needs: - - changes - - build-check - permissions: - contents: read - - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - INPUTBOX_RUN_UI_TESTS: '1' - INPUTBOX_UI_ARTIFACT_DIR: '${{ github.workspace }}/TestResults/UiArtifacts' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - cache: true - cache-dependency-path: | - **/*.csproj - .config/dotnet-tools.json - - - name: 下載建置產出(來自 build-check) - uses: actions/download-artifact@v8 - with: - name: build-output - path: . - - - name: 還原 NuGet 套件(供 MTP runner 偵測) - working-directory: ./tests/InputBox.Tests - # dotnet test --no-build 在 global.json MTP 模式下仍需 MSBuild 評估專案屬性, - # 必須先還原 NuGet 套件,否則 MTP props 無法匯入,導致誤判為 VSTest runner。 - run: dotnet restore InputBox.Tests.csproj - - - name: 執行 UI 冒煙測試(FlaUI Smoke) - id: ui_smoke - continue-on-error: true - working-directory: ./tests/InputBox.Tests - run: dotnet test --project InputBox.Tests.csproj -c Release --no-build --filter-trait "Category=UI" - - - name: 寫入 UI 冒煙測試警告摘要 - if: always() && steps.ui_smoke.outcome == 'failure' - run: | - "## ⚠️ UI 冒煙測試未通過" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "此工作為報告用途,不阻擋主建置與一般單元測試;請下載 Artifact 進行排查。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- Artifact 名稱:ui-smoke-artifacts" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- 優先檢查:桌面擷圖 PNG 與診斷 TXT" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- 診斷 TXT 會包含 lastStep、applicationWindows 與 openMenus 摘要,可直接定位卡住步驟。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - - name: 上傳 UI 冒煙測試失敗產物(Artifacts) - if: always() && steps.ui_smoke.outcome == 'failure' - uses: actions/upload-artifact@v7 - with: - name: ui-smoke-artifacts - path: TestResults/UiArtifacts - if-no-files-found: ignore - retention-days: 5 +name: 程式碼建置檢查 + +on: + pull_request: + branches: + # 應用程式採用 main 保持可發布、dev 每輪先對齊 main 再提交 PR 的守門流程。 + - main + +concurrency: + # 同一分支或 PR 只保留最新一次執行,降低重複建置浪費。 + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + ui: ${{ steps.filter.outputs.ui }} + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v7 + with: + fetch-depth: 2 + + - name: 判斷是否涉及 UI 冒煙測試範圍 + id: filter + uses: dorny/paths-filter@v4 + with: + filters: | + code: + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'eng/nuget/**' + - '.github/workflows/**' + - 'NuGet.config' + - 'global.json' + - '*.sln' + - '*.slnx' + - '**/*.csproj' + - '**/*.props' + - '**/*.targets' + ui: + - 'src/InputBox/MainForm*.cs' + - 'src/InputBox/MainForm.resx' + - 'src/InputBox/Core/Controls/**' + - 'src/InputBox/Resources/**' + - 'tests/InputBox.Tests/MainFormUiSmokeTests.cs' + - 'tests/InputBox.Tests/UiSmokeTestRequirements.cs' + + build-check: + if: needs.changes.outputs.code == 'true' + needs: changes + runs-on: windows-latest + timeout-minutes: 15 + permissions: + # 只需要讀取原始碼進行建置即可。 + contents: read + + # 定義環境變數,方便統一管理。 + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + # xUnit v3 MTP 的 coverage 輸出在 bin 輸出目錄的 TestResults 子目錄下。 + COVERAGE_GLOB: tests/**/coverage.cobertura.xml + REPORT_DIR: CoverageReport + # 行覆蓋率最低門檻(%)。低於此值時 CI 強制失敗。 + # WinForms UI 層雖已加入少量 FlaUI 冒煙測試,但大部分互動與硬體流程仍不納入 coverage 基線, + # 因此整體行覆蓋率仍維持在低檔,這裡僅作為基本迴歸守衛。 + # 若移除已測邏輯或測試檔,CI 應能即時失敗提醒。 + COVERAGE_THRESHOLD: '2' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v7 + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + # 使用 GitHub Actions 語法來讀取 env 變數。 + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + # 啟用快取功能,加速 NuGet 套件還原。 + cache: true + # 同時涵蓋主專案、測試專案與工具清單,確保 NuGet 套件快取完整。 + cache-dependency-path: | + **/*.csproj + NuGet.config + eng/nuget/*.sha256 + .config/dotnet-tools.json + + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" + } + + if (-not (Test-Path $checksumPath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" + } + + $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" + } + + $expectedCommit = "72697db594b8f1863bd3fbf5db75ff69b40e3364" + $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" + if (Test-Path $inspectDir) { + Remove-Item -LiteralPath $inspectDir -Recurse -Force + } + New-Item -ItemType Directory -Path $inspectDir | Out-Null + Expand-Archive -Path $packagePath -DestinationPath $inspectDir -Force + $nuspecPath = Get-ChildItem -LiteralPath $inspectDir -Filter "*.nuspec" -File | Select-Object -First 1 + if ($null -eq $nuspecPath) { + Write-Error "InputWeave.GameInput 套件缺少 nuspec。" + } + $nuspecText = Get-Content -LiteralPath $nuspecPath.FullName -Raw + if ($nuspecText -notmatch "commit=`"$expectedCommit`"") { + Write-Error "InputWeave.GameInput 套件封裝來源 commit 不符;必須為 $expectedCommit。" + } + + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" + Write-Host "InputWeave.GameInput 套件封裝來源 commit 驗證通過:$expectedCommit" + + - name: 還原主專案 NuGet 套件 + run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate + + - name: 建置應用程式與測試專案(Build App + Tests) + working-directory: ./tests/InputBox.Tests + # 測試專案會連同主專案相依一起建置,避免重複編譯浪費資源。 + run: dotnet build InputBox.Tests.csproj -c Release --no-incremental + + - name: 上傳建置產出(供 ui-smoke 重用) + if: needs.changes.outputs.ui == 'true' + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + tests/InputBox.Tests/bin/Release/ + src/InputBox/bin/Release/ + retention-days: 1 + if-no-files-found: error + + - name: 執行非 UI 單元測試並收集覆蓋率(Test + Coverage) + working-directory: ./tests/InputBox.Tests + # xUnit v3 + MTP 原生模式(global.json test.runner=Microsoft.Testing.Platform)。 + # UI 冒煙測試使用 FlaUI,與一般邏輯測試分流,避免桌面自動化影響 coverage 基線穩定度。 + # --no-build 重用上一步的建置產出;coverage 輸出至 bin/Release 目錄下的 TestResults 子目錄。 + run: | + dotnet test --project InputBox.Tests.csproj -c Release --no-build ` + --filter-not-trait "Category=UI" ` + --coverage ` + --coverage-output-format cobertura ` + --coverage-output coverage.cobertura.xml + + - name: 產生覆蓋率報告(ReportGenerator) + run: | + dotnet tool restore + dotnet tool run reportgenerator ` + "-reports:${{ env.COVERAGE_GLOB }}" ` + "-targetdir:${{ env.REPORT_DIR }}" ` + "-reporttypes:Html;Cobertura;MarkdownSummaryGithub" ` + "-filefilters:-*\obj\*" + + - name: 寫入覆蓋率摘要至 GitHub Step Summary + run: | + Get-Content -Path "${{ env.REPORT_DIR }}/SummaryGithub.md" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: 上傳覆蓋率報告(Artifact) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: ${{ env.REPORT_DIR }} + if-no-files-found: error + retention-days: 7 + + - name: 覆蓋率門檻檢查(Coverage Threshold Gate) + run: | + $xml = [xml](Get-Content -Path "${{ env.REPORT_DIR }}/Cobertura.xml" -Encoding UTF8) + $lineRate = [double]$xml.coverage.'line-rate' + $pct = [Math]::Round($lineRate * 100, 1) + Write-Host "目前行覆蓋率:$pct%(最低門檻:${{ env.COVERAGE_THRESHOLD }}%)" + if ($pct -lt [double]"${{ env.COVERAGE_THRESHOLD }}") { + Write-Host "##[error]覆蓋率 $pct% 低於最低門檻 ${{ env.COVERAGE_THRESHOLD }}%,請補充測試後再提交。" + exit 1 + } + Write-Host "覆蓋率檢查通過。" + + - name: 建置與測試成功通知 + if: success() + run: Write-Host "建置與測試全部通過!程式碼狀態良好。" + + ui-smoke: + if: needs.changes.outputs.ui == 'true' + runs-on: windows-latest + timeout-minutes: 8 + needs: + - changes + - build-check + permissions: + contents: read + + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + INPUTBOX_RUN_UI_TESTS: '1' + INPUTBOX_UI_ARTIFACT_DIR: '${{ github.workspace }}/TestResults/UiArtifacts' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v7 + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + cache: true + cache-dependency-path: | + **/*.csproj + .config/dotnet-tools.json + + - name: 下載建置產出(來自 build-check) + uses: actions/download-artifact@v8 + with: + name: build-output + path: . + + - name: 還原 NuGet 套件(供 MTP runner 偵測) + working-directory: ./tests/InputBox.Tests + # dotnet test --no-build 在 global.json MTP 模式下仍需 MSBuild 評估專案屬性, + # 必須先還原 NuGet 套件,否則 MTP props 無法匯入,導致誤判為 VSTest runner。 + run: dotnet restore InputBox.Tests.csproj + + - name: 執行 UI 冒煙測試(FlaUI Smoke) + id: ui_smoke + continue-on-error: true + working-directory: ./tests/InputBox.Tests + run: dotnet test --project InputBox.Tests.csproj -c Release --no-build --filter-trait "Category=UI" + + - name: 寫入 UI 冒煙測試警告摘要 + if: always() && steps.ui_smoke.outcome == 'failure' + run: | + "## ⚠️ UI 冒煙測試未通過" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "此工作為報告用途,不阻擋主建置與一般單元測試;請下載 Artifact 進行排查。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- Artifact 名稱:ui-smoke-artifacts" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- 優先檢查:桌面擷圖 PNG 與診斷 TXT" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- 診斷 TXT 會包含 lastStep、applicationWindows 與 openMenus 摘要,可直接定位卡住步驟。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: 上傳 UI 冒煙測試失敗產物(Artifacts) + if: always() && steps.ui_smoke.outcome == 'failure' + uses: actions/upload-artifact@v7 + with: + name: ui-smoke-artifacts + path: TestResults/UiArtifacts + if-no-files-found: ignore + retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b95a22..d3abacc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,608 +1,608 @@ -name: 建置與發佈 - -on: - push: - tags: - # 當推送符合語意版本的標籤(如 v1.0.0)時觸發,排除非版本標籤(如 vtest)。 - - 'v[0-9]*' - workflow_call: - secrets: - VT_API_KEY: - required: false - -concurrency: - # 相同 tag 的重複發版只保留最新一次,避免重覆占用 runner 與 API 配額。 - group: release-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-release: - runs-on: windows-latest - timeout-minutes: 25 - permissions: - contents: write - - # 定義環境變數,方便統一管理。 - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - DOTNET_NOLOGO: '1' - PUBLISH_DIR: "./src/InputBox/out" - EXE_NAME: 'InputBox.exe' - SHA256_HASH: '' - GAMEINPUT_REDIST_SHA256: '' - ZIP_FILENAME: '' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - with: - # 發版僅需目前 tag 對應提交,避免抓取完整歷史紀錄。 - fetch-depth: 1 - - - name: 驗證版本標籤來源為 main 最新提交 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # 僅抓取 main 的最新遠端頭,避免不必要的歷史下載。 - git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main - - $tagCommit = (git rev-list -n 1 "${{ github.ref_name }}").Trim() - $mainHead = (git rev-parse origin/main).Trim() - - Write-Host "Tag commit: $tagCommit" - Write-Host "main HEAD: $mainHead" - - if ([string]::IsNullOrWhiteSpace($tagCommit) -or [string]::IsNullOrWhiteSpace($mainHead)) { - Write-Error "無法解析版本標籤或 main 分支的提交資訊。" - } - - if ($tagCommit -ne $mainHead) { - Write-Error "正式版本標籤只能從 main 分支最新提交建立;目前 tag 未指向 origin/main 的最新 commit。" - } - - Write-Host "版本標籤來源驗證通過。" - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - # 使用 GitHub Actions 語法來讀取 env 變數。 - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 - - - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" - $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" - - if (-not (Test-Path $packagePath)) { - Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" - } - - if (-not (Test-Path $checksumPath)) { - Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" - } - - $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() - $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { - Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" - } - - $expectedCommit = "72697db594b8f1863bd3fbf5db75ff69b40e3364" - $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" - if (Test-Path $inspectDir) { - Remove-Item -LiteralPath $inspectDir -Recurse -Force - } - New-Item -ItemType Directory -Path $inspectDir | Out-Null - Expand-Archive -Path $packagePath -DestinationPath $inspectDir -Force - $nuspecPath = Get-ChildItem -LiteralPath $inspectDir -Filter "*.nuspec" -File | Select-Object -First 1 - if ($null -eq $nuspecPath) { - Write-Error "InputWeave.GameInput 套件缺少 nuspec。" - } - $nuspecText = Get-Content -LiteralPath $nuspecPath.FullName -Raw - if ($nuspecText -notmatch "commit=`"$expectedCommit`"") { - Write-Error "InputWeave.GameInput 套件封裝來源 commit 不符;必須為 $expectedCommit。" - } - - Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" - Write-Host "InputWeave.GameInput 套件封裝來源 commit 驗證通過:$expectedCommit" - - - name: 還原 NuGet 套件 - run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate - - - name: 讀取 Microsoft.GameInput 版本 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw - $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) - - if ($packageRefs.Count -ne 1) { - Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" - } - - $version = [string]$packageRefs[0].Version - if ([string]::IsNullOrWhiteSpace($version)) { - Write-Error "Microsoft.GameInput PackageReference 缺少明確 Version。" - } - - if ($version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { - Write-Error "Microsoft.GameInput Version 必須是明確穩定版本,不可使用 floating/range/prerelease:$version" - } - - "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "Microsoft.GameInput pinned version: $version" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - "Microsoft.GameInput pinned version: ``$version``" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - - - name: 檢查 Microsoft.GameInput 最新穩定版本(非阻擋) - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - $indexUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.gameinput/index.json" - - try { - $index = Invoke-RestMethod -Uri $indexUrl -ErrorAction Stop - $stableVersions = @( - $index.versions | - Where-Object { $_ -match '^\d+\.\d+\.\d+(?:\.\d+)?$' } | - Sort-Object { [version]$_ } - ) - - if ($stableVersions.Count -eq 0) { - throw "NuGet index 未回傳可解析的 stable 版本。" - } - - $latest = $stableVersions[-1] - Write-Host "Microsoft.GameInput pinned version: $env:GAMEINPUT_VERSION" - Write-Host "Microsoft.GameInput latest stable: $latest" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - @( - '', - '### Microsoft.GameInput 版本資訊', - '', - "- Pinned: ``$env:GAMEINPUT_VERSION``", - "- Latest stable: ``$latest``" - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - - if ($env:GAMEINPUT_VERSION -ne $latest) { - Write-Warning "Microsoft.GameInput pinned version ($env:GAMEINPUT_VERSION) 不是 NuGet 最新 stable ($latest);本檢查不阻擋發佈。" - } - } catch { - Write-Warning "無法查詢 Microsoft.GameInput 最新 stable 版本;本檢查不阻擋發佈。原因:$($_.Exception.Message)" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - @( - '', - '### Microsoft.GameInput 版本資訊', - '', - "- Pinned: ``$env:GAMEINPUT_VERSION``", - '- Latest stable: 查詢失敗(非阻擋)' - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - } - - - name: 執行專案發佈(Publish) - working-directory: ./src/InputBox - run: > - dotnet publish -c Release -r win-x64 - --self-contained true - -p:PublishSingleFile=true - -p:IncludeNativeLibrariesForSelfExtract=true - -p:PublishReadyToRun=true - -o out - - - name: 驗證單檔發佈輸出 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $exePath = Join-Path "${{ env.PUBLISH_DIR }}" "${{ env.EXE_NAME }}" - - if (-not (Test-Path $exePath)) { - Write-Error "找不到單檔發佈執行檔:$exePath" - } - - $forbiddenNames = @( - "InputBox.GameInput.Native.dll", - "gameinput.dll" - ) - $publishFiles = Get-ChildItem -LiteralPath "${{ env.PUBLISH_DIR }}" -Recurse -File - foreach ($name in $forbiddenNames) { - $match = $publishFiles | Where-Object { $_.Name.Equals($name, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 - if ($match) { - Write-Error "單檔發佈輸出不應包含可見的 GameInput sidecar:$($match.FullName)" - } - } - - Write-Host "單檔發佈輸出驗證通過:未留下 InputBox.GameInput.Native.dll 或 gameinput.dll sidecar。" - - - name: 自動產生第三方套件授權聲明(nuget-license) - shell: pwsh - run: | - dotnet tool restore - Write-Host "掃描專案 NuGet 套件並匯出 ThirdPartyNotices.txt……" - # -i:指定專案檔路徑。 - # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 - # -fo:指定輸出的檔案名稱(File Output)。 - dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -o Markdown -fo ThirdPartyNotices.txt -ignore Microsoft.GameInput - - if (Test-Path "ThirdPartyNotices.txt") { - Write-Host "✅ 成功產生專案依賴套件的 ThirdPartyNotices.txt!" - } else { - Write-Warning "⚠️ 未產生 ThirdPartyNotices.txt,可能是因為專案目前沒有依賴任何第三方套件。" - } - - - name: 補寫 InputWeave.GameInput 授權聲明 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $noticePath = "ThirdPartyNotices.txt" - - if (-not (Test-Path $noticePath)) { - New-Item -ItemType File -Path $noticePath -Force | Out-Null - } - - Add-Content -Path $noticePath -Encoding utf8 -Value '' - Add-Content -Path $noticePath -Encoding utf8 -Value '## InputWeave.GameInput' - Add-Content -Path $noticePath -Encoding utf8 -Value '' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件:InputWeave.GameInput 0.0.1' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 來源:https://github.com/rubujo/InputWeave.GameInput/tree/72697db594b8f1863bd3fbf5db75ff69b40e3364' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 作者:https://github.com/rubujo' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 貢獻者:https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 封裝來源:https://github.com/rubujo/InputWeave.GameInput/commit/72697db594b8f1863bd3fbf5db75ff69b40e3364' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件檔案:eng/nuget/InputWeave.GameInput.0.0.1.nupkg' - Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 451b9d65143eafea130ec3492f12a991b29c62d53509e74b856894848f329f5e' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 授權:Creative Commons CC0 1.0 Universal(SPDX:CC0-1.0);詳見本 Licenses 目錄中的 InputWeave.GameInput_LICENSE.txt 與 https://github.com/rubujo/InputWeave.GameInput/blob/72697db594b8f1863bd3fbf5db75ff69b40e3364/LICENSE。' - - - name: 產生執行檔的雜湊值(SHA256) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $exePath = Join-Path -Path "${{ env.PUBLISH_DIR }}" -ChildPath "${{ env.EXE_NAME }}" - - if (Test-Path $exePath) { - $hash = (Get-FileHash $exePath -Algorithm SHA256).Hash - "SHA256_HASH=$hash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "成功產生雜湊值:$hash" - } else { - Write-Error "找不到執行檔($exePath),請檢查發佈步驟!" - } - - - name: 打包並壓縮發佈檔案(ZIP Archive) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $tagName = "${{ github.ref_name }}" - $zipName = "InputBox-$tagName-win-x64.zip" - "ZIP_FILENAME=$zipName" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - # 1. 建立外層目錄(dist)、內層目錄(dist/InputBox)、授權子目錄與 redist 子目錄。 - $distDir = "./dist" - $packDir = Join-Path $distDir "InputBox" - $licensesDir = Join-Path $packDir "Licenses" - $redistDir = Join-Path $packDir "redist" - if (Test-Path $distDir) { - Remove-Item -Recurse -Force $distDir - } - New-Item -ItemType Directory -Force -Path $packDir | Out-Null - New-Item -ItemType Directory -Force -Path $licensesDir | Out-Null - New-Item -ItemType Directory -Force -Path $redistDir | Out-Null - - # 2. 複製執行檔與專案文件到內層目錄。 - # - 執行檔與 README.md 放根目錄。 - # - 專案授權(LICENSE)保留根目錄(業界慣例:使用者預期在根層找到它)。 - # - ThirdPartyNotices.txt 放入 Licenses/ 子目錄。 - Copy-Item -Path (Join-Path ${{ env.PUBLISH_DIR }} ${{ env.EXE_NAME }}) -Destination $packDir - $rootDocs = @("LICENSE", "README.md") - foreach ($doc in $rootDocs) { - if (Test-Path $doc) { - Copy-Item -Path $doc -Destination $packDir - } - } - if (Test-Path "ThirdPartyNotices.txt") { - Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir - } - Copy-Item -Path ".\eng\nuget\InputWeave.GameInput_LICENSE.txt" -Destination (Join-Path $licensesDir "InputWeave.GameInput_LICENSE.txt") - - # 3. 動態抓取 .NET 官方授權檔案(含最相近 Tag/分支 Fallback)。 - Write-Host "=== 開始動態下載 .NET 官方授權檔案 ===" - $runtimeInfo = dotnet --list-runtimes - - $majorVersion = "${{ env.DOTNET_MAJOR_VERSION }}" - $runtimeLine = $runtimeInfo | Where-Object { $_ -match "^Microsoft\.NETCore\.App ($majorVersion\.\d+\.\d+)" } | Select-Object -Last 1 - $githubHeaders = @{ - 'User-Agent' = 'InputBox-Release-Workflow' - 'X-GitHub-Api-Version' = '2022-11-28' - } - - function Get-TagStageRank { - param([string]$TagName) - - if ($TagName -match '(?i)preview') { return 3 } - if ($TagName -match '(?i)rc') { return 2 } - if ($TagName -match '(?i)rtm|servicing') { return 1 } - return 0 - } - - function Get-ClosestDotnetTag { - param( - [string]$Repo, - [version]$TargetVersion - ) - - $tagsApiUrl = "https://api.github.com/repos/dotnet/$Repo/tags?per_page=100" - - try { - $tagResponse = Invoke-RestMethod -Uri $tagsApiUrl -Headers $githubHeaders -ErrorAction Stop - } catch { - Write-Warning "⚠️ 無法查詢 dotnet/$Repo 的 Tag 清單:$($_.Exception.Message)" - return $null - } - - $candidates = foreach ($tagItem in $tagResponse) { - if ($tagItem.name -match '^v(?\d+)\.(?\d+)\.(?\d+)') { - [pscustomobject]@{ - Name = $tagItem.name - Major = [int]$Matches['major'] - Minor = [int]$Matches['minor'] - Patch = [int]$Matches['patch'] - StageRank = Get-TagStageRank -TagName $tagItem.name - } - } - } - - $nearest = $candidates | - Where-Object { $_.Major -eq $TargetVersion.Major -and $_.Minor -eq $TargetVersion.Minor } | - Sort-Object ` - @{ Expression = { [Math]::Abs($_.Patch - $TargetVersion.Build) } }, ` - @{ Expression = { $_.StageRank } }, ` - @{ Expression = { $_.Patch }; Descending = $true } | - Select-Object -First 1 - - return $nearest.Name - } - - function Save-GitHubFileWithFallback { - param( - [string]$Repo, - [string[]]$Refs, - [string]$File, - [string]$OutPath - ) - - foreach ($ref in ($Refs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { - $rawUrl = "https://raw.githubusercontent.com/dotnet/$Repo/$ref/$File" - $metadataUrl = "https://api.github.com/repos/dotnet/$Repo/contents/$File?ref=$([uri]::EscapeDataString($ref))" - Write-Host "嘗試下載:dotnet/$Repo@$ref -> $File" - - try { - Invoke-WebRequest -Uri $rawUrl -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop - Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / raw)" - return $true - } catch { - try { - $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers $githubHeaders -ErrorAction Stop - if (-not [string]::IsNullOrWhiteSpace($metadata.download_url)) { - Invoke-WebRequest -Uri $metadata.download_url -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop - Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / api)" - return $true - } - } catch { - # Ignore and continue to the next candidate below. - } - - Write-Warning "⚠️ 無法從 $ref 下載 $File,繼續嘗試下一個候選來源。" - } - } - - return $false - } - - if ($runtimeLine -match "^Microsoft\.NETCore\.App (\d+\.\d+\.\d+)") { - $version = $Matches[1] - $tag = "v$version" - $targetVersion = [version]$version - $releaseBranch = "release/$($targetVersion.Major).$($targetVersion.Minor)" - Write-Host "偵測到目前使用的 .NET Runtime 版本為:$version,優先使用 GitHub 標籤:$tag" - - $filesToDownload = @( - @{ Repo = "runtime"; File = "LICENSE.TXT"; Out = "DOTNET_RUNTIME_LICENSE.txt" }, - @{ Repo = "runtime"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_RUNTIME_THIRD_PARTY_NOTICES.txt" }, - @{ Repo = "winforms"; File = "LICENSE.TXT"; Out = "DOTNET_WINFORMS_LICENSE.txt" }, - @{ Repo = "winforms"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_WINFORMS_THIRD_PARTY_NOTICES.txt" } - ) - - $repoRefCandidates = @{} - foreach ($repoName in ($filesToDownload.Repo | Select-Object -Unique)) { - $closestTag = Get-ClosestDotnetTag -Repo $repoName -TargetVersion $targetVersion - if ($closestTag) { - Write-Host "dotnet/$repoName 最相近的可用 Tag 候選:$closestTag" - } else { - Write-Warning "⚠️ dotnet/$repoName 找不到符合 $($targetVersion.Major).$($targetVersion.Minor).x 的 Tag,將直接退回分支。" - } - - $repoRefCandidates[$repoName] = @($tag, $closestTag, $releaseBranch, 'main') - } - - foreach ($item in $filesToDownload) { - $outPath = Join-Path $licensesDir $item.Out - $downloaded = Save-GitHubFileWithFallback -Repo $item.Repo -Refs $repoRefCandidates[$item.Repo] -File $item.File -OutPath $outPath - if (-not $downloaded) { - Write-Warning "⚠️ 最終仍無法取得 $($item.Out)。" - } - } - } else { - Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" - } - - # 4. 打包 Microsoft GameInput redist 與授權檔案。 - Write-Host "=== 開始處理 Microsoft GameInput redist 與授權 ===" - $gameInputPackageDir = Join-Path $env:USERPROFILE ".nuget\packages\microsoft.gameinput\$env:GAMEINPUT_VERSION" - $gameInputRedistSource = Join-Path $gameInputPackageDir "redist\GameInputRedist.msi" - $gameInputLicenseSource = Join-Path $gameInputPackageDir "LICENSE.txt" - $gameInputNoticeSource = Join-Path $gameInputPackageDir "NOTICE.txt" - - if (-not (Test-Path $gameInputRedistSource)) { - Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" - } - - if (-not (Test-Path $gameInputLicenseSource)) { - Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" - } - - if (-not (Test-Path $gameInputNoticeSource)) { - Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" - } - - Copy-Item -Path $gameInputRedistSource -Destination (Join-Path $redistDir "GameInputRedist.msi") - Copy-Item -Path $gameInputLicenseSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_LICENSE.txt") - Copy-Item -Path $gameInputNoticeSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_NOTICE.txt") - - $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash - "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - $redistReadme = @( - 'Microsoft GameInput Redistributable', - '===================================', - '', - "Version: $env:GAMEINPUT_VERSION", - 'Included file: GameInputRedist.msi', - "SHA256: $redistHash", - '', - 'InputBox bundles this redistributable for optional manual installation only.', - 'InputBox does not execute this installer automatically and does not require administrator elevation during normal application startup.', - '', - 'License: see ../Licenses/Microsoft.GameInput_LICENSE.txt', - 'Third-party notices: see ../Licenses/Microsoft.GameInput_NOTICE.txt' - ) -join "`n" - $redistReadme | Out-File -FilePath (Join-Path $redistDir "README.txt") -Encoding utf8 - - if (Test-Path "ThirdPartyNotices.txt") { - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '## Microsoft GameInput Redistributable' - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Package: Microsoft.GameInput $env:GAMEINPUT_VERSION" - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Source: https://www.nuget.org/packages/Microsoft.GameInput/$env:GAMEINPUT_VERSION" - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Included file: redist/GameInputRedist.msi' - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- SHA256: $redistHash" - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Install policy: bundled for optional manual installation; InputBox does not install it automatically.' - Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- License: see Microsoft.GameInput_LICENSE.txt and Microsoft.GameInput_NOTICE.txt in this Licenses directory.' - Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir -Force - } else { - Write-Error "找不到 ThirdPartyNotices.txt,無法加入 Microsoft GameInput redist 聲明。" - } - Write-Host "=======================================" - - # 5. 執行壓縮並驗證 ZIP 內容。 - Compress-Archive -Path $packDir -DestinationPath $zipName -CompressionLevel Optimal - - Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $zipName)) - try { - $entries = $zip.Entries | ForEach-Object { $_.FullName } - $requiredEntries = @( - 'InputBox/InputBox.exe', - 'InputBox/LICENSE', - 'InputBox/README.md', - 'InputBox/Licenses/ThirdPartyNotices.txt', - 'InputBox/Licenses/InputWeave.GameInput_LICENSE.txt', - 'InputBox/Licenses/Microsoft.GameInput_LICENSE.txt', - 'InputBox/Licenses/Microsoft.GameInput_NOTICE.txt', - 'InputBox/redist/GameInputRedist.msi', - 'InputBox/redist/README.txt' - ) - foreach ($entry in $requiredEntries) { - if ($entries -notcontains $entry) { - Write-Error "ZIP 缺少必要項目:$entry" - } - } - - $forbiddenPatterns = @( - 'GameInputNet_LICENSE.txt', - 'UsbVendorsLibrary_LICENSE.txt', - 'usb_ids_LICENSE.txt', - 'gameinput.dll', - 'InputBox.GameInput.Native.dll' - ) - foreach ($pattern in $forbiddenPatterns) { - if ($entries | Where-Object { $_ -like "*$pattern" }) { - Write-Error "ZIP 包含禁止項目:$pattern" - } - } - - $thirdParty = $zip.GetEntry('InputBox/Licenses/ThirdPartyNotices.txt') - if ($null -eq $thirdParty) { - Write-Error "ZIP 缺少 ThirdPartyNotices.txt,無法驗證 InputWeave.GameInput 聲明。" - } - - $reader = [System.IO.StreamReader]::new($thirdParty.Open()) - try { - $thirdPartyText = $reader.ReadToEnd() - if ($thirdPartyText -notmatch 'InputWeave\.GameInput') { - Write-Error "ThirdPartyNotices.txt 缺少 InputWeave.GameInput 聲明。" - } - } finally { - $reader.Dispose() - } - } finally { - $zip.Dispose() - } - - - name: 檢查 VirusTotal API Key 設定 - id: vt-config - shell: pwsh - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - if ([string]::IsNullOrWhiteSpace($env:VT_API_KEY)) { - "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Warning "未設定 VT_API_KEY,略過 VirusTotal 掃描。" - } else { - "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "已偵測到 VT_API_KEY,將進行 VirusTotal 掃描。" - } - - - name: 建立 GitHub Release - id: release - uses: softprops/action-gh-release@v3 - with: - files: ${{ env.ZIP_FILENAME }} - # 根據 Commit 與 PR 自動生成版本說明(Changelog)。 - generate_release_notes: true - body: | - ## 📦 輸入框發佈摘要 - - **版本號碼**:`${{ github.ref_name }}` - - **目標平台**:Windows x64 - - **執行環境**:.NET 10(已隨附 Runtime,無需額外安裝) - - **執行檔案的雜湊值(SHA256)**:`${{ env.SHA256_HASH }}` - - **GameInput Redist(選用手動安裝)SHA256**:`${{ env.GAMEINPUT_REDIST_SHA256 }}` - - --- - > [!NOTE] - > 壓縮檔內含 Microsoft GameInput Redistributable (`redist/GameInputRedist.msi`) 供需要者手動安裝;InputBox 不會自動執行該安裝程式,GameInput 不可用時會退避至 XInput。 - - > [!NOTE] - > 本版本由 GitHub Actions 自動建置並發佈。詳細變更內容請參考下方的自動產生說明。 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: 使用 VirusTotal 掃描並更新 Release - if: ${{ steps.vt-config.outputs.enabled == 'true' }} - continue-on-error: true - uses: cssnr/virustotal-action@v2 - with: - vt_api_key: ${{ secrets.VT_API_KEY }} - release_id: ${{ steps.release.outputs.id }} - rate_limit: 4 - sha256: true - update_release: true - release_heading: "🛡️ **VirusTotal 掃描結果:**" - summary: true +name: 建置與發佈 + +on: + push: + tags: + # 當推送符合語意版本的標籤(如 v1.0.0)時觸發,排除非版本標籤(如 vtest)。 + - 'v[0-9]*' + workflow_call: + secrets: + VT_API_KEY: + required: false + +concurrency: + # 相同 tag 的重複發版只保留最新一次,避免重覆占用 runner 與 API 配額。 + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-release: + runs-on: windows-latest + timeout-minutes: 25 + permissions: + contents: write + + # 定義環境變數,方便統一管理。 + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + DOTNET_NOLOGO: '1' + PUBLISH_DIR: "./src/InputBox/out" + EXE_NAME: 'InputBox.exe' + SHA256_HASH: '' + GAMEINPUT_REDIST_SHA256: '' + ZIP_FILENAME: '' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v7 + with: + # 發版僅需目前 tag 對應提交,避免抓取完整歷史紀錄。 + fetch-depth: 1 + + - name: 驗證版本標籤來源為 main 最新提交 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # 僅抓取 main 的最新遠端頭,避免不必要的歷史下載。 + git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main + + $tagCommit = (git rev-list -n 1 "${{ github.ref_name }}").Trim() + $mainHead = (git rev-parse origin/main).Trim() + + Write-Host "Tag commit: $tagCommit" + Write-Host "main HEAD: $mainHead" + + if ([string]::IsNullOrWhiteSpace($tagCommit) -or [string]::IsNullOrWhiteSpace($mainHead)) { + Write-Error "無法解析版本標籤或 main 分支的提交資訊。" + } + + if ($tagCommit -ne $mainHead) { + Write-Error "正式版本標籤只能從 main 分支最新提交建立;目前 tag 未指向 origin/main 的最新 commit。" + } + + Write-Host "版本標籤來源驗證通過。" + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + # 使用 GitHub Actions 語法來讀取 env 變數。 + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 + + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" + } + + if (-not (Test-Path $checksumPath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" + } + + $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" + } + + $expectedCommit = "72697db594b8f1863bd3fbf5db75ff69b40e3364" + $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" + if (Test-Path $inspectDir) { + Remove-Item -LiteralPath $inspectDir -Recurse -Force + } + New-Item -ItemType Directory -Path $inspectDir | Out-Null + Expand-Archive -Path $packagePath -DestinationPath $inspectDir -Force + $nuspecPath = Get-ChildItem -LiteralPath $inspectDir -Filter "*.nuspec" -File | Select-Object -First 1 + if ($null -eq $nuspecPath) { + Write-Error "InputWeave.GameInput 套件缺少 nuspec。" + } + $nuspecText = Get-Content -LiteralPath $nuspecPath.FullName -Raw + if ($nuspecText -notmatch "commit=`"$expectedCommit`"") { + Write-Error "InputWeave.GameInput 套件封裝來源 commit 不符;必須為 $expectedCommit。" + } + + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" + Write-Host "InputWeave.GameInput 套件封裝來源 commit 驗證通過:$expectedCommit" + + - name: 還原 NuGet 套件 + run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate + + - name: 讀取 Microsoft.GameInput 版本 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw + $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) + + if ($packageRefs.Count -ne 1) { + Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" + } + + $version = [string]$packageRefs[0].Version + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "Microsoft.GameInput PackageReference 缺少明確 Version。" + } + + if ($version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { + Write-Error "Microsoft.GameInput Version 必須是明確穩定版本,不可使用 floating/range/prerelease:$version" + } + + "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "Microsoft.GameInput pinned version: $version" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + "Microsoft.GameInput pinned version: ``$version``" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + + - name: 檢查 Microsoft.GameInput 最新穩定版本(非阻擋) + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $indexUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.gameinput/index.json" + + try { + $index = Invoke-RestMethod -Uri $indexUrl -ErrorAction Stop + $stableVersions = @( + $index.versions | + Where-Object { $_ -match '^\d+\.\d+\.\d+(?:\.\d+)?$' } | + Sort-Object { [version]$_ } + ) + + if ($stableVersions.Count -eq 0) { + throw "NuGet index 未回傳可解析的 stable 版本。" + } + + $latest = $stableVersions[-1] + Write-Host "Microsoft.GameInput pinned version: $env:GAMEINPUT_VERSION" + Write-Host "Microsoft.GameInput latest stable: $latest" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + @( + '', + '### Microsoft.GameInput 版本資訊', + '', + "- Pinned: ``$env:GAMEINPUT_VERSION``", + "- Latest stable: ``$latest``" + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + + if ($env:GAMEINPUT_VERSION -ne $latest) { + Write-Warning "Microsoft.GameInput pinned version ($env:GAMEINPUT_VERSION) 不是 NuGet 最新 stable ($latest);本檢查不阻擋發佈。" + } + } catch { + Write-Warning "無法查詢 Microsoft.GameInput 最新 stable 版本;本檢查不阻擋發佈。原因:$($_.Exception.Message)" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + @( + '', + '### Microsoft.GameInput 版本資訊', + '', + "- Pinned: ``$env:GAMEINPUT_VERSION``", + '- Latest stable: 查詢失敗(非阻擋)' + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + } + + - name: 執行專案發佈(Publish) + working-directory: ./src/InputBox + run: > + dotnet publish -c Release -r win-x64 + --self-contained true + -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true + -p:PublishReadyToRun=true + -o out + + - name: 驗證單檔發佈輸出 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exePath = Join-Path "${{ env.PUBLISH_DIR }}" "${{ env.EXE_NAME }}" + + if (-not (Test-Path $exePath)) { + Write-Error "找不到單檔發佈執行檔:$exePath" + } + + $forbiddenNames = @( + "InputBox.GameInput.Native.dll", + "gameinput.dll" + ) + $publishFiles = Get-ChildItem -LiteralPath "${{ env.PUBLISH_DIR }}" -Recurse -File + foreach ($name in $forbiddenNames) { + $match = $publishFiles | Where-Object { $_.Name.Equals($name, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 + if ($match) { + Write-Error "單檔發佈輸出不應包含可見的 GameInput sidecar:$($match.FullName)" + } + } + + Write-Host "單檔發佈輸出驗證通過:未留下 InputBox.GameInput.Native.dll 或 gameinput.dll sidecar。" + + - name: 自動產生第三方套件授權聲明(nuget-license) + shell: pwsh + run: | + dotnet tool restore + Write-Host "掃描專案 NuGet 套件並匯出 ThirdPartyNotices.txt……" + # -i:指定專案檔路徑。 + # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 + # -fo:指定輸出的檔案名稱(File Output)。 + dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -o Markdown -fo ThirdPartyNotices.txt -ignore Microsoft.GameInput + + if (Test-Path "ThirdPartyNotices.txt") { + Write-Host "✅ 成功產生專案依賴套件的 ThirdPartyNotices.txt!" + } else { + Write-Warning "⚠️ 未產生 ThirdPartyNotices.txt,可能是因為專案目前沒有依賴任何第三方套件。" + } + + - name: 補寫 InputWeave.GameInput 授權聲明 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $noticePath = "ThirdPartyNotices.txt" + + if (-not (Test-Path $noticePath)) { + New-Item -ItemType File -Path $noticePath -Force | Out-Null + } + + Add-Content -Path $noticePath -Encoding utf8 -Value '' + Add-Content -Path $noticePath -Encoding utf8 -Value '## InputWeave.GameInput' + Add-Content -Path $noticePath -Encoding utf8 -Value '' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件:InputWeave.GameInput 0.0.1' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 來源:https://github.com/rubujo/InputWeave.GameInput/tree/72697db594b8f1863bd3fbf5db75ff69b40e3364' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 作者:https://github.com/rubujo' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 貢獻者:https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 封裝來源:https://github.com/rubujo/InputWeave.GameInput/commit/72697db594b8f1863bd3fbf5db75ff69b40e3364' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件檔案:eng/nuget/InputWeave.GameInput.0.0.1.nupkg' + Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 451b9d65143eafea130ec3492f12a991b29c62d53509e74b856894848f329f5e' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 授權:Creative Commons CC0 1.0 Universal(SPDX:CC0-1.0);詳見本 Licenses 目錄中的 InputWeave.GameInput_LICENSE.txt 與 https://github.com/rubujo/InputWeave.GameInput/blob/72697db594b8f1863bd3fbf5db75ff69b40e3364/LICENSE。' + + - name: 產生執行檔的雜湊值(SHA256) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exePath = Join-Path -Path "${{ env.PUBLISH_DIR }}" -ChildPath "${{ env.EXE_NAME }}" + + if (Test-Path $exePath) { + $hash = (Get-FileHash $exePath -Algorithm SHA256).Hash + "SHA256_HASH=$hash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "成功產生雜湊值:$hash" + } else { + Write-Error "找不到執行檔($exePath),請檢查發佈步驟!" + } + + - name: 打包並壓縮發佈檔案(ZIP Archive) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tagName = "${{ github.ref_name }}" + $zipName = "InputBox-$tagName-win-x64.zip" + "ZIP_FILENAME=$zipName" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + # 1. 建立外層目錄(dist)、內層目錄(dist/InputBox)、授權子目錄與 redist 子目錄。 + $distDir = "./dist" + $packDir = Join-Path $distDir "InputBox" + $licensesDir = Join-Path $packDir "Licenses" + $redistDir = Join-Path $packDir "redist" + if (Test-Path $distDir) { + Remove-Item -Recurse -Force $distDir + } + New-Item -ItemType Directory -Force -Path $packDir | Out-Null + New-Item -ItemType Directory -Force -Path $licensesDir | Out-Null + New-Item -ItemType Directory -Force -Path $redistDir | Out-Null + + # 2. 複製執行檔與專案文件到內層目錄。 + # - 執行檔與 README.md 放根目錄。 + # - 專案授權(LICENSE)保留根目錄(業界慣例:使用者預期在根層找到它)。 + # - ThirdPartyNotices.txt 放入 Licenses/ 子目錄。 + Copy-Item -Path (Join-Path ${{ env.PUBLISH_DIR }} ${{ env.EXE_NAME }}) -Destination $packDir + $rootDocs = @("LICENSE", "README.md") + foreach ($doc in $rootDocs) { + if (Test-Path $doc) { + Copy-Item -Path $doc -Destination $packDir + } + } + if (Test-Path "ThirdPartyNotices.txt") { + Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir + } + Copy-Item -Path ".\eng\nuget\InputWeave.GameInput_LICENSE.txt" -Destination (Join-Path $licensesDir "InputWeave.GameInput_LICENSE.txt") + + # 3. 動態抓取 .NET 官方授權檔案(含最相近 Tag/分支 Fallback)。 + Write-Host "=== 開始動態下載 .NET 官方授權檔案 ===" + $runtimeInfo = dotnet --list-runtimes + + $majorVersion = "${{ env.DOTNET_MAJOR_VERSION }}" + $runtimeLine = $runtimeInfo | Where-Object { $_ -match "^Microsoft\.NETCore\.App ($majorVersion\.\d+\.\d+)" } | Select-Object -Last 1 + $githubHeaders = @{ + 'User-Agent' = 'InputBox-Release-Workflow' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + function Get-TagStageRank { + param([string]$TagName) + + if ($TagName -match '(?i)preview') { return 3 } + if ($TagName -match '(?i)rc') { return 2 } + if ($TagName -match '(?i)rtm|servicing') { return 1 } + return 0 + } + + function Get-ClosestDotnetTag { + param( + [string]$Repo, + [version]$TargetVersion + ) + + $tagsApiUrl = "https://api.github.com/repos/dotnet/$Repo/tags?per_page=100" + + try { + $tagResponse = Invoke-RestMethod -Uri $tagsApiUrl -Headers $githubHeaders -ErrorAction Stop + } catch { + Write-Warning "⚠️ 無法查詢 dotnet/$Repo 的 Tag 清單:$($_.Exception.Message)" + return $null + } + + $candidates = foreach ($tagItem in $tagResponse) { + if ($tagItem.name -match '^v(?\d+)\.(?\d+)\.(?\d+)') { + [pscustomobject]@{ + Name = $tagItem.name + Major = [int]$Matches['major'] + Minor = [int]$Matches['minor'] + Patch = [int]$Matches['patch'] + StageRank = Get-TagStageRank -TagName $tagItem.name + } + } + } + + $nearest = $candidates | + Where-Object { $_.Major -eq $TargetVersion.Major -and $_.Minor -eq $TargetVersion.Minor } | + Sort-Object ` + @{ Expression = { [Math]::Abs($_.Patch - $TargetVersion.Build) } }, ` + @{ Expression = { $_.StageRank } }, ` + @{ Expression = { $_.Patch }; Descending = $true } | + Select-Object -First 1 + + return $nearest.Name + } + + function Save-GitHubFileWithFallback { + param( + [string]$Repo, + [string[]]$Refs, + [string]$File, + [string]$OutPath + ) + + foreach ($ref in ($Refs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { + $rawUrl = "https://raw.githubusercontent.com/dotnet/$Repo/$ref/$File" + $metadataUrl = "https://api.github.com/repos/dotnet/$Repo/contents/$File?ref=$([uri]::EscapeDataString($ref))" + Write-Host "嘗試下載:dotnet/$Repo@$ref -> $File" + + try { + Invoke-WebRequest -Uri $rawUrl -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop + Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / raw)" + return $true + } catch { + try { + $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers $githubHeaders -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($metadata.download_url)) { + Invoke-WebRequest -Uri $metadata.download_url -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop + Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / api)" + return $true + } + } catch { + # Ignore and continue to the next candidate below. + } + + Write-Warning "⚠️ 無法從 $ref 下載 $File,繼續嘗試下一個候選來源。" + } + } + + return $false + } + + if ($runtimeLine -match "^Microsoft\.NETCore\.App (\d+\.\d+\.\d+)") { + $version = $Matches[1] + $tag = "v$version" + $targetVersion = [version]$version + $releaseBranch = "release/$($targetVersion.Major).$($targetVersion.Minor)" + Write-Host "偵測到目前使用的 .NET Runtime 版本為:$version,優先使用 GitHub 標籤:$tag" + + $filesToDownload = @( + @{ Repo = "runtime"; File = "LICENSE.TXT"; Out = "DOTNET_RUNTIME_LICENSE.txt" }, + @{ Repo = "runtime"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_RUNTIME_THIRD_PARTY_NOTICES.txt" }, + @{ Repo = "winforms"; File = "LICENSE.TXT"; Out = "DOTNET_WINFORMS_LICENSE.txt" }, + @{ Repo = "winforms"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_WINFORMS_THIRD_PARTY_NOTICES.txt" } + ) + + $repoRefCandidates = @{} + foreach ($repoName in ($filesToDownload.Repo | Select-Object -Unique)) { + $closestTag = Get-ClosestDotnetTag -Repo $repoName -TargetVersion $targetVersion + if ($closestTag) { + Write-Host "dotnet/$repoName 最相近的可用 Tag 候選:$closestTag" + } else { + Write-Warning "⚠️ dotnet/$repoName 找不到符合 $($targetVersion.Major).$($targetVersion.Minor).x 的 Tag,將直接退回分支。" + } + + $repoRefCandidates[$repoName] = @($tag, $closestTag, $releaseBranch, 'main') + } + + foreach ($item in $filesToDownload) { + $outPath = Join-Path $licensesDir $item.Out + $downloaded = Save-GitHubFileWithFallback -Repo $item.Repo -Refs $repoRefCandidates[$item.Repo] -File $item.File -OutPath $outPath + if (-not $downloaded) { + Write-Warning "⚠️ 最終仍無法取得 $($item.Out)。" + } + } + } else { + Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" + } + + # 4. 打包 Microsoft GameInput redist 與授權檔案。 + Write-Host "=== 開始處理 Microsoft GameInput redist 與授權 ===" + $gameInputPackageDir = Join-Path $env:USERPROFILE ".nuget\packages\microsoft.gameinput\$env:GAMEINPUT_VERSION" + $gameInputRedistSource = Join-Path $gameInputPackageDir "redist\GameInputRedist.msi" + $gameInputLicenseSource = Join-Path $gameInputPackageDir "LICENSE.txt" + $gameInputNoticeSource = Join-Path $gameInputPackageDir "NOTICE.txt" + + if (-not (Test-Path $gameInputRedistSource)) { + Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" + } + + if (-not (Test-Path $gameInputLicenseSource)) { + Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" + } + + if (-not (Test-Path $gameInputNoticeSource)) { + Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" + } + + Copy-Item -Path $gameInputRedistSource -Destination (Join-Path $redistDir "GameInputRedist.msi") + Copy-Item -Path $gameInputLicenseSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_LICENSE.txt") + Copy-Item -Path $gameInputNoticeSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_NOTICE.txt") + + $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash + "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + $redistReadme = @( + 'Microsoft GameInput Redistributable', + '===================================', + '', + "Version: $env:GAMEINPUT_VERSION", + 'Included file: GameInputRedist.msi', + "SHA256: $redistHash", + '', + 'InputBox bundles this redistributable for optional manual installation only.', + 'InputBox does not execute this installer automatically and does not require administrator elevation during normal application startup.', + '', + 'License: see ../Licenses/Microsoft.GameInput_LICENSE.txt', + 'Third-party notices: see ../Licenses/Microsoft.GameInput_NOTICE.txt' + ) -join "`n" + $redistReadme | Out-File -FilePath (Join-Path $redistDir "README.txt") -Encoding utf8 + + if (Test-Path "ThirdPartyNotices.txt") { + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '## Microsoft GameInput Redistributable' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Package: Microsoft.GameInput $env:GAMEINPUT_VERSION" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Source: https://www.nuget.org/packages/Microsoft.GameInput/$env:GAMEINPUT_VERSION" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Included file: redist/GameInputRedist.msi' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- SHA256: $redistHash" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Install policy: bundled for optional manual installation; InputBox does not install it automatically.' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- License: see Microsoft.GameInput_LICENSE.txt and Microsoft.GameInput_NOTICE.txt in this Licenses directory.' + Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir -Force + } else { + Write-Error "找不到 ThirdPartyNotices.txt,無法加入 Microsoft GameInput redist 聲明。" + } + Write-Host "=======================================" + + # 5. 執行壓縮並驗證 ZIP 內容。 + Compress-Archive -Path $packDir -DestinationPath $zipName -CompressionLevel Optimal + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $zipName)) + try { + $entries = $zip.Entries | ForEach-Object { $_.FullName } + $requiredEntries = @( + 'InputBox/InputBox.exe', + 'InputBox/LICENSE', + 'InputBox/README.md', + 'InputBox/Licenses/ThirdPartyNotices.txt', + 'InputBox/Licenses/InputWeave.GameInput_LICENSE.txt', + 'InputBox/Licenses/Microsoft.GameInput_LICENSE.txt', + 'InputBox/Licenses/Microsoft.GameInput_NOTICE.txt', + 'InputBox/redist/GameInputRedist.msi', + 'InputBox/redist/README.txt' + ) + foreach ($entry in $requiredEntries) { + if ($entries -notcontains $entry) { + Write-Error "ZIP 缺少必要項目:$entry" + } + } + + $forbiddenPatterns = @( + 'GameInputNet_LICENSE.txt', + 'UsbVendorsLibrary_LICENSE.txt', + 'usb_ids_LICENSE.txt', + 'gameinput.dll', + 'InputBox.GameInput.Native.dll' + ) + foreach ($pattern in $forbiddenPatterns) { + if ($entries | Where-Object { $_ -like "*$pattern" }) { + Write-Error "ZIP 包含禁止項目:$pattern" + } + } + + $thirdParty = $zip.GetEntry('InputBox/Licenses/ThirdPartyNotices.txt') + if ($null -eq $thirdParty) { + Write-Error "ZIP 缺少 ThirdPartyNotices.txt,無法驗證 InputWeave.GameInput 聲明。" + } + + $reader = [System.IO.StreamReader]::new($thirdParty.Open()) + try { + $thirdPartyText = $reader.ReadToEnd() + if ($thirdPartyText -notmatch 'InputWeave\.GameInput') { + Write-Error "ThirdPartyNotices.txt 缺少 InputWeave.GameInput 聲明。" + } + } finally { + $reader.Dispose() + } + } finally { + $zip.Dispose() + } + + - name: 檢查 VirusTotal API Key 設定 + id: vt-config + shell: pwsh + env: + VT_API_KEY: ${{ secrets.VT_API_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:VT_API_KEY)) { + "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Warning "未設定 VT_API_KEY,略過 VirusTotal 掃描。" + } else { + "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "已偵測到 VT_API_KEY,將進行 VirusTotal 掃描。" + } + + - name: 建立 GitHub Release + id: release + uses: softprops/action-gh-release@v3 + with: + files: ${{ env.ZIP_FILENAME }} + # 根據 Commit 與 PR 自動生成版本說明(Changelog)。 + generate_release_notes: true + body: | + ## 📦 輸入框發佈摘要 + - **版本號碼**:`${{ github.ref_name }}` + - **目標平台**:Windows x64 + - **執行環境**:.NET 10(已隨附 Runtime,無需額外安裝) + - **執行檔案的雜湊值(SHA256)**:`${{ env.SHA256_HASH }}` + - **GameInput Redist(選用手動安裝)SHA256**:`${{ env.GAMEINPUT_REDIST_SHA256 }}` + + --- + > [!NOTE] + > 壓縮檔內含 Microsoft GameInput Redistributable (`redist/GameInputRedist.msi`) 供需要者手動安裝;InputBox 不會自動執行該安裝程式,GameInput 不可用時會退避至 XInput。 + + > [!NOTE] + > 本版本由 GitHub Actions 自動建置並發佈。詳細變更內容請參考下方的自動產生說明。 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 使用 VirusTotal 掃描並更新 Release + if: ${{ steps.vt-config.outputs.enabled == 'true' }} + continue-on-error: true + uses: cssnr/virustotal-action@v2 + with: + vt_api_key: ${{ secrets.VT_API_KEY }} + release_id: ${{ steps.release.outputs.id }} + rate_limit: 4 + sha256: true + update_release: true + release_heading: "🛡️ **VirusTotal 掃描結果:**" + summary: true