Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 86 additions & 105 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jobs:
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
tag_name: ${{ steps.extract_notes.outputs.tag_name }}
version: ${{ steps.extract_notes.outputs.version }}
Expand All @@ -23,7 +26,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
fetch-depth: 1

- name: Extract version and verify CHANGELOG
id: extract_notes
Expand All @@ -43,43 +46,48 @@ jobs:
exit 1
}

$existingTagCommit = git rev-parse -q --verify "refs/tags/$tag^{commit}"
if ($LASTEXITCODE -eq 0) {
if ($existingTagCommit.Trim() -ne $headCommit) {
Write-Error "Preflight 失敗:Tag $tag 已存在且指向不同 commit ($($existingTagCommit.Trim())),請更新版本號!"
$existingTag = [string](git tag -l $tag)
if (-not [string]::IsNullOrWhiteSpace($existingTag)) {
$ref = "refs/tags/" + $tag + "^{commit}"
$existingTagCommit = (git rev-parse -q --verify $ref).Trim()
if ($LASTEXITCODE -ne 0 -or $existingTagCommit -ne $headCommit) {
Write-Error "Preflight 失敗:Tag $tag 已存在且指向不同 commit [$existingTagCommit],請更新版本號!"
exit 1
}
Write-Host "Tag $tag 已存在且指向當前 HEAD,允許發布覆蓋/更新。"
Write-Host "Tag $tag 已存在且指向當前 HEAD;將沿用該標籤建立 Release。"
} else {
Write-Host "Tag $tag 尚未建立,可安全建立新標籤發布。"
}

gitleaks:
name: 隱私與機密資訊檢查 (Gitleaks)
needs: preflight
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: 隱私與機密資訊檢查 (Gitleaks)
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
- name: 確認 master HEAD 的 CI 工作流已全數完成
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# 品質門禁、CodeQL、機密掃描已於 push 到 master 時執行,發布流程不重複建置驗證,僅確認同一 commit 的結果
$runs = gh run list --repo $env:GITHUB_REPOSITORY --commit $env:GITHUB_SHA `
--event push --limit 50 --json workflowName,status,conclusion,createdAt | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw '無法查詢 workflow runs' }

$latest = @($runs | Sort-Object createdAt -Descending | Group-Object workflowName | ForEach-Object { $_.Group[0] })
$pending = @($latest | Where-Object { $_.status -ne 'completed' })
if ($pending.Count -gt 0) {
throw "master HEAD 尚有未完成的工作流:$($pending.workflowName -join ', '),請待其完成後再發布"
}

foreach ($name in 'CI', 'CodeQL Analysis (C#)') {
$run = $latest | Where-Object { $_.workflowName -eq $name }
if ($null -eq $run -or $run.conclusion -ne 'success') {
throw "工作流 [$name] 在 $env:GITHUB_SHA 上未成功或尚未執行,禁止發布"
}
Write-Host "[$name] 已成功"
}

build:
name: Publish and Attest (.NET 10)
needs: [preflight, gitleaks]
if: github.ref == 'refs/heads/master'
needs: preflight
runs-on: windows-latest
timeout-minutes: 30
outputs:
tag_name: ${{ steps.release_info.outputs.tag_name }}
version: ${{ steps.release_info.outputs.version }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
DOTNET_CLI_FORCE_UTF8_ENCODING: "1"
Expand All @@ -95,42 +103,23 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
fetch-depth: 1

- name: Setup .NET 10 SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: |
global.json
Directory.Packages.props
src/**/*.csproj
tests/**/*.csproj

- name: Extract version and release notes
id: release_info
shell: pwsh
run: .\scripts\extract_release_notes.ps1 -Strict

- name: Run C# Quality Gate
shell: pwsh
run: .\scripts\dotnet_quality_gate.ps1

- name: Publish Dual Executables (Self-contained & Framework-dependent)
shell: pwsh
run: .\scripts\dotnet_publish.ps1

- name: Verify Release Artifacts in output
shell: pwsh
run: |
$outputDir = "output"
if (-not (Test-Path $outputDir)) {
throw "輸出目錄不存在: $outputDir"
}

Write-Host "發布目錄驗證完成,檔案列表:"
Get-ChildItem -Path $outputDir | Select-Object Name, Length | Format-Table -AutoSize

- name: Attest release executable provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
Expand Down Expand Up @@ -183,7 +172,7 @@ jobs:
- name: Generate SBOM (SPDX 2.2)
shell: pwsh
env:
RELEASE_VERSION: ${{ steps.release_info.outputs.version }}
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
SBOM_TOOL_VERSION: v4.1.5
SBOM_TOOL_SHA256: 625767b371b7fdd58f40f618b8a86da0247a33c89e419039c86b4edba1dad4b5
run: |
Expand Down Expand Up @@ -230,67 +219,53 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release_payload
path: |
output/
release_notes.md
path: output/
if-no-files-found: error
retention-days: 2

publish:
name: Publish Release
needs: build
if: github.ref == 'refs/heads/master'
needs: [preflight, build]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
attestations: read
env:
RELEASE_TAG: ${{ needs.build.outputs.tag_name }}
RELEASE_TAG: ${{ needs.preflight.outputs.tag_name }}

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
fetch-depth: 1

- name: Download release payload
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release_payload
path: .
path: output

- name: Generate release notes
shell: pwsh
run: ./scripts/extract_release_notes.ps1 -Strict

- name: Create release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
if ($env:GITHUB_REF_NAME -ne 'master') {
Write-Error "此 workflow 只能在 master 分支上手動執行,目前是 $env:GITHUB_REF_NAME"
exit 1
}

git fetch --tags --force
$tag = $env:RELEASE_TAG
$headCommit = (git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or -not $headCommit) {
Write-Error "無法取得目前 commit"
exit 1
}

$existingTagCommit = git rev-parse -q --verify "refs/tags/$tag^{commit}"
if ($LASTEXITCODE -eq 0) {
if ($existingTagCommit.Trim() -ne $headCommit) {
Write-Error "Tag $tag 已存在,但指向不同 commit;請改用新版本號"
exit 1
}

Write-Host "Tag already exists and matches HEAD: $tag"
if ([string](git tag -l $tag)) {
Write-Host "Tag $tag 已存在(Preflight 已驗證指向當前 HEAD),略過建立。"
exit 0
}

$headCommit = (git rev-parse HEAD).Trim()

$tagRequest = @{
tag = $tag
message = $tag
Expand Down Expand Up @@ -329,17 +304,22 @@ jobs:
$tag = $env:RELEASE_TAG
$prereleaseArgs = @()
if ($tag -match '^\d+\.\d+\.\d+-') { $prereleaseArgs += '--prerelease' }
$assets = Get-ChildItem -Path 'output' -File | Where-Object {
$_.Name -match '^MinecraftServerManager.*\.exe$' -or
$_.Name -match '^MinecraftServerManager.*\.exe\.sha256$' -or
$_.Name -match '^MinecraftServerManager.*\.exe\.intoto\.jsonl$' -or
$_.Name -match '^MinecraftServerManager.*\.spdx\.json$' -or
$_.Name -match '^MinecraftServerManager.*\.spdx\.json\.sha256$'
} | ForEach-Object { $_.FullName }
if (-not $assets -or $assets.Count -eq 0) {
Write-Error "No release executable was found in output."
exit 1
$expectedAssets = @(
'MinecraftServerManager-self-contained.exe',
'MinecraftServerManager-self-contained.exe.sha256',
'MinecraftServerManager-self-contained.exe.intoto.jsonl',
'MinecraftServerManager-framework-dependent.exe',
'MinecraftServerManager-framework-dependent.exe.sha256',
'MinecraftServerManager-framework-dependent.exe.intoto.jsonl',
'MinecraftServerManager.spdx.json',
'MinecraftServerManager.spdx.json.sha256'
)
$actualNames = @(Get-ChildItem -Path 'output' -File | Select-Object -ExpandProperty Name)
$missingAssets = @($expectedAssets | Where-Object { $_ -notin $actualNames })
if ($missingAssets.Count -gt 0) {
throw "Release payload 缺少必要資產:$($missingAssets -join ', ')"
}
$assets = @($expectedAssets | ForEach-Object { Join-Path 'output' $_ })
$ghArgs = @('release', 'create', $tag) + $assets + @(
'--title', "Minecraft Server Manager $tag",
'--notes-file', 'release_notes.md',
Expand Down Expand Up @@ -386,14 +366,30 @@ jobs:
Write-Host "成功下載 $($downloadedFiles.Count) 個發布資產:"
$downloadedFiles | Select-Object Name, Length | Format-Table -AutoSize

$expectedAssets = @(
'MinecraftServerManager-self-contained.exe',
'MinecraftServerManager-self-contained.exe.sha256',
'MinecraftServerManager-self-contained.exe.intoto.jsonl',
'MinecraftServerManager-framework-dependent.exe',
'MinecraftServerManager-framework-dependent.exe.sha256',
'MinecraftServerManager-framework-dependent.exe.intoto.jsonl',
'MinecraftServerManager.spdx.json',
'MinecraftServerManager.spdx.json.sha256'
)
$actualNames = @($downloadedFiles | Select-Object -ExpandProperty Name)
$missingAssets = @($expectedAssets | Where-Object { $_ -notin $actualNames })
if ($missingAssets.Count -gt 0) {
throw "Release 資產不完整,缺少:$($missingAssets -join ', ')"
}

$exeFiles = @(Get-ChildItem -LiteralPath $verifyDir -Filter '*.exe' -File)
if ($exeFiles.Count -eq 0) {
throw "驗證失敗:Release 中未找到任何 .exe 執行檔"
if ($exeFiles.Count -ne 2) {
throw "驗證失敗:預期 2 個 .exe,實際找到 $($exeFiles.Count) 個"
}

$shaFiles = @(Get-ChildItem -LiteralPath $verifyDir -Filter '*.sha256' -File)
if ($shaFiles.Count -eq 0) {
throw "驗證失敗:Release 中未找到任何 .sha256 雜湊檔"
if ($shaFiles.Count -ne 3) {
throw "驗證失敗:預期 3 個 .sha256,實際找到 $($shaFiles.Count) 個"
}

Write-Host "--- 步驟 1:比對 SHA-256 雜湊值 ---"
Expand All @@ -418,7 +414,7 @@ jobs:
}
}

Write-Host "--- 步驟 2:驗證 .exe 的 GitHub SLSA Attestation ---"
Write-Host "--- 步驟 2:驗證 .exe 的 GitHub SLSA Attestation(同時確認與建置產物摘要一致) ---"
foreach ($exe in $exeFiles) {
Write-Host "正在驗證 $($exe.Name) 的簽章證明..."
gh attestation verify $exe.FullName --repo $env:GITHUB_REPOSITORY
Expand All @@ -428,21 +424,6 @@ jobs:
Write-Host " [Attestation 驗證通過] $($exe.Name)"
}

Write-Host "--- 步驟 3:比對建置產物一致性(本地 output/ vs 遠端 Release) ---"
if (Test-Path -LiteralPath 'output') {
foreach ($exe in $exeFiles) {
$localExe = Join-Path 'output' $exe.Name
if (Test-Path -LiteralPath $localExe -PathType Leaf) {
$localHash = (Get-FileHash -LiteralPath $localExe -Algorithm SHA256).Hash.ToUpperInvariant()
$remoteHash = (Get-FileHash -LiteralPath $exe.FullName -Algorithm SHA256).Hash.ToUpperInvariant()
if ($localHash -ne $remoteHash) {
throw "一致性校驗失敗:$($exe.Name) 本地建置雜湊 ($localHash) 與 Release 遠端雜湊 ($remoteHash) 不符!"
}
Write-Host " [工作區一致性驗證通過] $($exe.Name)"
}
}
}

Write-Host "=== 發布後端對端資產驗證全數通過!==="
}
finally {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
env:
MSBUILDDISABLENODEREUSE: "1"
DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: "1"
run: dotnet build src/MinecraftServerManager.App/MinecraftServerManager.App.csproj -c Release
run: dotnet build src/MinecraftServerManager.App/MinecraftServerManager.App.csproj -c Release -p:UseSharedCompilation=false

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
Expand Down
9 changes: 1 addition & 8 deletions .github/workflows/oss-scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jobs:
analysis:
name: Scorecard analyze
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
security-events: write
id-token: write
Expand All @@ -25,7 +26,6 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0

- name: "執行 Scorecard 分析"
uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 (Scorecard v5.5.0)
Expand All @@ -39,10 +39,3 @@ jobs:
with:
sarif_file: openossf_scorecard_report.sarif
category: "OpenSSF Scorecard"

- name: "上傳 Scorecard 結果檔案"
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: openossf_scorecard_report
path: openossf_scorecard_report.sarif
retention-days: 2
5 changes: 0 additions & 5 deletions .github/workflows/validate-renovate.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
name: Validate Renovate config

on:
push:
paths:
- "renovate.json"
- ".github/renovate.json"
pull_request:
paths:
- "renovate.json"
- ".github/renovate.json"

permissions:
Expand Down
Loading