From 0b0650ef137de3261ef64f05a4092d2e6eab6eb5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:16:14 +0900 Subject: [PATCH] Harden GitHub Actions dependencies --- .github/workflows/benchmark-regression.yml | 88 ++++++--- .github/workflows/codeql.yml | 54 +++++- .github/workflows/dotnet.yml | 95 ++++++---- .github/workflows/release.yml | 26 +-- CHANGELOG.md | 4 + .../CiAutomationConfigurationTests.cs | 168 +++++++++++++++++- 6 files changed, 356 insertions(+), 79 deletions(-) diff --git a/.github/workflows/benchmark-regression.yml b/.github/workflows/benchmark-regression.yml index a121675e..d18bbee6 100644 --- a/.github/workflows/benchmark-regression.yml +++ b/.github/workflows/benchmark-regression.yml @@ -22,25 +22,24 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read jobs: benchmark-regression: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -85,8 +84,66 @@ jobs: echo "Baseline branch gh-benchmarks does not exist yet. Will be created on first push to main." fi + - name: Detect performance regression + if: steps.check-baseline.outputs.exists == 'true' + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + name: 'FolderDiffIL4DotNet Performance' + tool: 'benchmarkdotnet' + output-file-path: BenchmarkDotNet.Artifacts/results/combined-report.json + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: false + alert-threshold: '200%' + comment-on-alert: false + fail-on-alert: true + summary-always: true + gh-pages-branch: gh-benchmarks + benchmark-data-dir-path: dev/bench + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BenchmarkResults + if-no-files-found: warn + path: BenchmarkDotNet.Artifacts/** + + - name: Upload combined benchmark report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BenchmarkCombinedReport + if-no-files-found: error + path: BenchmarkDotNet.Artifacts/results/combined-report.json + + publish-benchmark-baseline: + needs: benchmark-regression + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Download combined benchmark report + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: BenchmarkCombinedReport + path: BenchmarkDotNet.Artifacts/results + + - name: Check if baseline branch exists + id: check-baseline + run: | + if git ls-remote --exit-code origin gh-benchmarks >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + - name: Create gh-benchmarks branch if missing - if: steps.check-baseline.outputs.exists != 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: steps.check-baseline.outputs.exists != 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -96,27 +153,18 @@ jobs: git push origin gh-benchmarks git checkout "$GITHUB_SHA" - - name: Detect performance regression - if: steps.check-baseline.outputs.exists == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - uses: benchmark-action/github-action-benchmark@v1 + - name: Publish trusted benchmark baseline + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 with: name: 'FolderDiffIL4DotNet Performance' tool: 'benchmarkdotnet' output-file-path: BenchmarkDotNet.Artifacts/results/combined-report.json github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + auto-push: true alert-threshold: '200%' - comment-on-alert: true - fail-on-alert: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + comment-on-alert: false + fail-on-alert: true summary-always: true gh-pages-branch: gh-benchmarks benchmark-data-dir-path: dev/bench skip-fetch-gh-pages: ${{ steps.check-baseline.outputs.exists != 'true' }} - - - name: Upload benchmark results - if: always() - uses: actions/upload-artifact@v7 - with: - name: BenchmarkResults - if-no-files-found: warn - path: BenchmarkDotNet.Artifacts/** diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c054e5da..610733aa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,14 +24,16 @@ on: workflow_dispatch: permissions: - actions: read contents: read - security-events: write jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: read strategy: fail-fast: false matrix: @@ -40,18 +42,18 @@ jobs: - actions steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET if: ${{ matrix.language == 'csharp' }} - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3 with: languages: ${{ matrix.language }} @@ -64,4 +66,44 @@ jobs: run: dotnet build FolderDiffIL4DotNet.sln --configuration Release --no-restore - name: Analyze - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3 + with: + output: codeql-raw-results + post-processed-sarif-path: codeql-results + upload: never + upload-database: false + + - name: Upload local CodeQL results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: CodeQL-SARIF-${{ matrix.language }} + if-no-files-found: error + path: codeql-results/** + + upload-results: + name: Upload results + needs: analyze + runs-on: ubuntu-latest + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - csharp + - actions + steps: + - name: Download local CodeQL results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: CodeQL-SARIF-${{ matrix.language }} + path: codeql-results + + - name: Upload CodeQL results + uses: github/codeql-action/upload-sarif@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3 + with: + category: ".github/workflows/codeql.yml:analyze/language:${{ matrix.language }}/" + sarif_file: codeql-results diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c196afa5..17e5d8bb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -14,15 +14,19 @@ on: permissions: contents: read +env: + DOCFX_VERSION: 2.78.5 + DOTNET_ILDASM_VERSION: 0.12.2 + jobs: javascript-tests: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .node-version cache: npm @@ -41,17 +45,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -74,7 +78,7 @@ jobs: run: dotnet build FolderDiffIL4DotNet.sln --configuration Release --no-restore - name: Install DocFX - run: dotnet tool update --global docfx --version '2.*' + run: dotnet tool update --global docfx --version "$DOCFX_VERSION" - name: Generate documentation site run: | @@ -85,7 +89,7 @@ jobs: - name: Install real disassembler for E2E tests if: ${{ hashFiles('FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj') != '' }} run: | - dotnet tool install --global dotnet-ildasm + dotnet tool install --global dotnet-ildasm --version "$DOTNET_ILDASM_VERSION" echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" DOTNET_ROLL_FORWARD=Major "$HOME/.dotnet/tools/dotnet-ildasm" --version @@ -222,7 +226,7 @@ jobs: - name: Upload test and coverage artifacts if: ${{ always() && hashFiles('FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj') != '' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: TestAndCoverage if-no-files-found: warn @@ -232,7 +236,7 @@ jobs: CoverageReport/** - name: Upload documentation artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: DocumentationSite path: _site/** @@ -244,7 +248,7 @@ jobs: run: find publish -name '*.pdb' -delete - name: Upload artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: FolderDiffIL4DotNet path: publish/** @@ -254,20 +258,19 @@ jobs: if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' permissions: contents: read - issues: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -285,7 +288,7 @@ jobs: - name: Install real disassembler for E2E tests run: | - dotnet tool install --global dotnet-ildasm + dotnet tool install --global dotnet-ildasm --version "$DOTNET_ILDASM_VERSION" echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" DOTNET_ROLL_FORWARD=Major "$HOME/.dotnet/tools/dotnet-ildasm" --version @@ -307,22 +310,9 @@ jobs: if: always() run: cat StrykerOutput/mutation-summary.md >> "$GITHUB_STEP_SUMMARY" - - name: Post mutation summary to pull request - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - continue-on-error: true - uses: actions/github-script@v7 - with: - script: | - const { upsertMutationSummaryComment } = require('./scripts/update-mutation-pr-comment.js'); - await upsertMutationSummaryComment({ - github, - context, - summaryPath: 'StrykerOutput/mutation-summary.md', - }); - - name: Upload mutation visibility summary if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: StrykerSummary-${{ github.run_number }}-${{ github.run_attempt }} if-no-files-found: error @@ -332,28 +322,57 @@ jobs: - name: Upload mutation testing report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: StrykerReport-${{ github.run_number }}-${{ github.run_attempt }} if-no-files-found: warn path: StrykerOutput/** + mutation-comment: + runs-on: ubuntu-latest + needs: mutation-testing + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download mutation visibility summary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: StrykerSummary-${{ github.run_number }}-${{ github.run_attempt }} + path: StrykerOutput + + - name: Post mutation summary to pull request + continue-on-error: true + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const { upsertMutationSummaryComment } = require('./scripts/update-mutation-pr-comment.js'); + await upsertMutationSummaryComment({ + github, + context, + summaryPath: 'StrykerOutput/mutation-summary.md', + }); + benchmark: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -368,7 +387,7 @@ jobs: - name: Upload benchmark results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: BenchmarkResults if-no-files-found: warn @@ -378,17 +397,17 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~\AppData\Local\NuGet\packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -404,7 +423,7 @@ jobs: - name: Install real disassembler for E2E tests shell: pwsh run: | - dotnet tool install --global dotnet-ildasm + dotnet tool install --global dotnet-ildasm --version $env:DOTNET_ILDASM_VERSION $toolPath = Join-Path $env:USERPROFILE ".dotnet\tools" $toolPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append & (Join-Path $toolPath "dotnet-ildasm.exe") --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9769a112..2f27113e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,20 @@ on: type: string permissions: - contents: write + contents: read + +env: + DOCFX_VERSION: 2.78.5 + DOTNET_ILDASM_VERSION: 0.12.2 jobs: release: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # On workflow_dispatch, force refs/tags/ so a branch name @@ -31,12 +37,12 @@ jobs: ref: ${{ inputs.tag_name && format('refs/tags/{0}', inputs.tag_name) || github.ref }} - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -50,7 +56,7 @@ jobs: run: dotnet build FolderDiffIL4DotNet.sln --configuration Release --no-restore - name: Install DocFX - run: dotnet tool update --global docfx --version '2.*' + run: dotnet tool update --global docfx --version "$DOCFX_VERSION" - name: Generate documentation site run: | @@ -61,7 +67,7 @@ jobs: - name: Install real disassembler for E2E tests if: ${{ hashFiles('FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj') != '' }} run: | - dotnet tool install --global dotnet-ildasm + dotnet tool install --global dotnet-ildasm --version "$DOTNET_ILDASM_VERSION" echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" DOTNET_ROLL_FORWARD=Major "$HOME/.dotnet/tools/dotnet-ildasm" --version @@ -125,7 +131,7 @@ jobs: sha256sum artifacts/*.zip > "artifacts/sha256-${tag_name}.txt" - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ReleaseArtifacts path: artifacts/** @@ -152,7 +158,7 @@ jobs: GITHUB_PACKAGES_SOURCE: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # See release job for rationale. @@ -160,12 +166,12 @@ jobs: ref: ${{ inputs.tag_name && format('refs/tags/{0}', inputs.tag_name) || github.ref }} - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8af8d8..19f682dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### Changed +- **GitHub Actions now use immutable dependencies and least-privilege jobs** — Every third-party action is pinned to a reviewed full commit SHA with an adjacent release-version comment, while DocFX and `dotnet-ildasm` installations use exact versions. Mutation-summary comments, benchmark baseline updates, and CodeQL result uploads now run in separate write-capable jobs restricted to same-repository pull requests, `main` pushes, or other trusted events. Validation and CodeQL analysis remain read-only and continue to run for fork-originated pull requests. Dependabot continues to cover pinned GitHub Actions. Affected: `.github/workflows/dotnet.yml`, `.github/workflows/benchmark-regression.yml`, `.github/workflows/codeql.yml`, `.github/workflows/release.yml`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`. + - **Spinner themes are hidden Easter eggs** — The existing themed and random spinner flags remain parseable for backward compatibility, but normal `--help` output and current user-facing documentation no longer advertise them. Regression tests protect both hidden help output and documentation consistency. Affected: `Runner/ProgramRunner.HelpText.cs`, `USER_GUIDE.md`, `doc/config.sample.jsonc`, `doc/DEVELOPER_GUIDE.md`, `doc/TESTING_GUIDE.md`, `FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`. - **CLI parsing now returns one structured result and rejects surplus positional arguments** — `CliParser` now separates `oldFolder`, `newFolder`, and optional `reportLabel` while consuming options in one pass. Existing two- and three-positional forms, option placement, automatic labels, and `--creator` behavior are preserved; a fourth positional argument now prints usage and exits with code `2`. `CliOptions` uses named properties with defaults instead of a 35-field positional constructor. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`. @@ -1696,6 +1698,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), #### 変更 +- **GitHub Actions の依存関係を不変化し、ジョブを最小権限化** — すべての third-party Action を、隣接するリリース版コメント付きのレビュー済み完全 commit SHA に固定し、DocFX と `dotnet-ildasm` のインストールも正確なバージョンへ固定しました。mutation summary の PR コメント、benchmark baseline 更新、CodeQL 結果 upload は、同一リポジトリ由来 PR、`main` push、またはその他の信頼済みイベントに限定した write 権限付き専用ジョブへ分離しました。検証と CodeQL 解析は read-only のまま fork 由来 PR でも実行します。Dependabot は固定済み GitHub Actions を引き続き更新対象にします。対象: `.github/workflows/dotnet.yml`, `.github/workflows/benchmark-regression.yml`, `.github/workflows/codeql.yml`, `.github/workflows/release.yml`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`。 + - **スピナーテーマを非表示のイースターエッグとして整理** — 既存のテーマ指定およびランダム選択フラグは後方互換のため引き続き解析できますが、通常の `--help` 出力と現行のユーザー向け文書では案内しないようにしました。ヘルプでの非表示と文書整合性を回帰テストで保護します。対象: `Runner/ProgramRunner.HelpText.cs`, `USER_GUIDE.md`, `doc/config.sample.jsonc`, `doc/DEVELOPER_GUIDE.md`, `doc/TESTING_GUIDE.md`, `FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`。 - **CLI 解析を単一の構造化結果へ集約し、余分な位置引数を拒否** — `CliParser` はオプションを 1 回の走査で消費しながら、`oldFolder`、`newFolder`、任意の `reportLabel` を分離するようになりました。既存の 2/3 位置引数形式、オプション位置、自動ラベル、`--creator` の動作は維持し、4 個目の位置引数は使い方を表示して終了コード `2` で拒否します。`CliOptions` は 35 フィールドの位置指定コンストラクタではなく、既定値付きの名前付きプロパティを使用します。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`。 diff --git a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs index cbdb8143..07fc9c12 100644 --- a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs +++ b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs @@ -214,7 +214,10 @@ public void DotNetWorkflow_RunsPinnedJavaScriptTestsAndAuditGate() Assert.Equal("24.18.0", nodeVersion); Assert.Contains("javascript-tests:", workflow, StringComparison.Ordinal); - Assert.Contains("uses: actions/setup-node@v6", workflow, StringComparison.Ordinal); + Assert.Contains( + "uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0", + workflow, + StringComparison.Ordinal); Assert.Contains("node-version-file: .node-version", workflow, StringComparison.Ordinal); Assert.Contains("cache: npm", workflow, StringComparison.Ordinal); Assert.Contains("cache-dependency-path: package-lock.json", workflow, StringComparison.Ordinal); @@ -520,6 +523,8 @@ public void Workflows_EnableRealDisassemblerE2EInCi() Assert.Contains("FOLDERDIFF_RUN_E2E: true", releaseWorkflow, StringComparison.Ordinal); Assert.Contains(".dotnet/tools", dotnetWorkflow, StringComparison.Ordinal); Assert.Contains(".dotnet/tools", releaseWorkflow, StringComparison.Ordinal); + Assert.Contains("DOTNET_ILDASM_VERSION: 0.12.2", dotnetWorkflow, StringComparison.Ordinal); + Assert.Contains("DOTNET_ILDASM_VERSION: 0.12.2", releaseWorkflow, StringComparison.Ordinal); } /// @@ -534,7 +539,10 @@ public void BenchmarkRegressionWorkflow_DetectsPerformanceDegradation() Assert.Contains("name: Performance Regression Test", workflow, StringComparison.Ordinal); Assert.Contains("pull_request:", workflow, StringComparison.Ordinal); - Assert.Contains("benchmark-action/github-action-benchmark@v1", workflow, StringComparison.Ordinal); + Assert.Contains( + "benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1", + workflow, + StringComparison.Ordinal); Assert.Contains("alert-threshold: '200%'", workflow, StringComparison.Ordinal); Assert.Contains("fail-on-alert:", workflow, StringComparison.Ordinal); Assert.Contains("FolderDiffIL4DotNet.Benchmarks", workflow, StringComparison.Ordinal); @@ -552,8 +560,18 @@ public void SecurityAutomation_EnablesCodeQlAndDependabot() var codeqlWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "codeql.yml")); var dependabotConfig = File.ReadAllText(GetRepositoryFilePath(".github", "dependabot.yml")); - Assert.Contains("github/codeql-action/init@v3", codeqlWorkflow, StringComparison.Ordinal); - Assert.Contains("github/codeql-action/analyze@v3", codeqlWorkflow, StringComparison.Ordinal); + Assert.Contains( + "github/codeql-action/init@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3", + codeqlWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "github/codeql-action/analyze@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3", + codeqlWorkflow, + StringComparison.Ordinal); + Assert.Contains( + "github/codeql-action/upload-sarif@4187e74d05793876e9989daffde9c3e66b4acd07 # v3.37.3", + codeqlWorkflow, + StringComparison.Ordinal); Assert.Contains("- csharp", codeqlWorkflow, StringComparison.Ordinal); Assert.Contains("- actions", codeqlWorkflow, StringComparison.Ordinal); Assert.Contains("schedule:", codeqlWorkflow, StringComparison.Ordinal); @@ -565,6 +583,143 @@ public void SecurityAutomation_EnablesCodeQlAndDependabot() Assert.Contains("interval: \"weekly\"", dependabotConfig, StringComparison.Ordinal); } + /// + /// Verifies that third-party actions and downloaded CI tools use immutable reviewed versions. + /// third-party Action と CI で取得するツールが、不変かつレビュー済みのバージョンを使うことを検証します。 + /// + [Fact] + [Trait("Category", "Unit")] + public void Workflows_PinThirdPartyActionsAndDownloadedTools() + { + var workflowDirectory = GetRepositoryFilePath(".github", "workflows"); + var usesLinePattern = new Regex( + @"^\s*uses:\s+(?.+?)\s*$", + RegexOptions.Multiline); + var usesPattern = new Regex( + @"^\s*uses:\s+(?[^@\s]+)@(?[^\s#]+)(?:\s+#\s+(?\S+))?\s*$", + RegexOptions.Multiline); + var shaPattern = new Regex(@"^[0-9a-f]{40}$", RegexOptions.CultureInvariant); + var versionPattern = new Regex(@"^v\d+\.\d+\.\d+$", RegexOptions.CultureInvariant); + + foreach (var workflowPath in Directory.GetFiles( + workflowDirectory, + "*.yml", + SearchOption.TopDirectoryOnly)) + { + var workflow = File.ReadAllText(workflowPath); + var usesLines = usesLinePattern.Matches(workflow); + Assert.NotEmpty(usesLines); + + foreach (Match usesLine in usesLines) + { + var actionReference = usesLine.Groups["value"].Value; + if (actionReference.StartsWith("./", StringComparison.Ordinal)) + { + continue; + } + + var match = usesPattern.Match(usesLine.Value); + Assert.True( + match.Success, + $"Third-party action reference must use a full SHA and adjacent version comment: {usesLine.Value.Trim()}"); + Assert.Matches(shaPattern, match.Groups["reference"].Value); + Assert.Matches(versionPattern, match.Groups["version"].Value); + } + } + + var dotnetWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "dotnet.yml")); + var releaseWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "release.yml")); + + Assert.Contains("DOCFX_VERSION: 2.78.5", dotnetWorkflow, StringComparison.Ordinal); + Assert.Contains("DOCFX_VERSION: 2.78.5", releaseWorkflow, StringComparison.Ordinal); + Assert.Contains("DOTNET_ILDASM_VERSION: 0.12.2", dotnetWorkflow, StringComparison.Ordinal); + Assert.Contains("DOTNET_ILDASM_VERSION: 0.12.2", releaseWorkflow, StringComparison.Ordinal); + Assert.DoesNotContain("--version '2.*'", dotnetWorkflow, StringComparison.Ordinal); + Assert.DoesNotContain("--version '2.*'", releaseWorkflow, StringComparison.Ordinal); + Assert.DoesNotMatch( + new Regex(@"dotnet tool install --global dotnet-ildasm\s*$", RegexOptions.Multiline), + dotnetWorkflow); + Assert.DoesNotMatch( + new Regex(@"dotnet tool install --global dotnet-ildasm\s*$", RegexOptions.Multiline), + releaseWorkflow); + } + + /// + /// Verifies that write permissions are isolated to trusted event-specific jobs. + /// write 権限が信頼済みイベント専用ジョブに分離されることを検証します。 + /// + [Fact] + [Trait("Category", "Unit")] + public void Workflows_RestrictWritePermissionsToTrustedEvents() + { + var dotnetWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "dotnet.yml")); + var benchmarkWorkflow = File.ReadAllText( + GetRepositoryFilePath(".github", "workflows", "benchmark-regression.yml")); + var codeqlWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "codeql.yml")); + var releaseWorkflow = File.ReadAllText(GetRepositoryFilePath(".github", "workflows", "release.yml")); + + var mutationTestingStart = dotnetWorkflow.IndexOf(" mutation-testing:", StringComparison.Ordinal); + var mutationCommentStart = dotnetWorkflow.IndexOf(" mutation-comment:", StringComparison.Ordinal); + var benchmarkStart = dotnetWorkflow.IndexOf(" benchmark:", StringComparison.Ordinal); + Assert.True(mutationTestingStart >= 0); + Assert.True(mutationCommentStart > mutationTestingStart); + Assert.True(benchmarkStart > mutationCommentStart); + + var mutationTestingSection = dotnetWorkflow[mutationTestingStart..mutationCommentStart]; + var mutationCommentSection = dotnetWorkflow[mutationCommentStart..benchmarkStart]; + Assert.Contains("permissions:\n contents: read", mutationTestingSection, StringComparison.Ordinal); + Assert.DoesNotContain("issues: write", mutationTestingSection, StringComparison.Ordinal); + Assert.Contains( + "if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository", + mutationCommentSection, + StringComparison.Ordinal); + Assert.Contains("issues: write", mutationCommentSection, StringComparison.Ordinal); + + Assert.Contains("permissions:\n contents: read", benchmarkWorkflow, StringComparison.Ordinal); + Assert.Single( + Regex.Matches(benchmarkWorkflow, @"^\s+contents: write\s*$", RegexOptions.Multiline)); + var benchmarkPublishStart = benchmarkWorkflow.IndexOf( + " publish-benchmark-baseline:", + StringComparison.Ordinal); + Assert.True(benchmarkPublishStart >= 0); + var benchmarkReadOnlySection = benchmarkWorkflow[..benchmarkPublishStart]; + Assert.Contains( + "if: github.event_name == 'push' && github.ref == 'refs/heads/main'", + benchmarkWorkflow, + StringComparison.Ordinal); + Assert.Contains("auto-push: false", benchmarkReadOnlySection, StringComparison.Ordinal); + Assert.Contains("comment-on-alert: false", benchmarkReadOnlySection, StringComparison.Ordinal); + Assert.Contains("fail-on-alert: true", benchmarkReadOnlySection, StringComparison.Ordinal); + + var codeqlAnalyzeStart = codeqlWorkflow.IndexOf(" analyze:", StringComparison.Ordinal); + var codeqlUploadStart = codeqlWorkflow.IndexOf(" upload-results:", StringComparison.Ordinal); + Assert.True(codeqlAnalyzeStart >= 0); + Assert.True(codeqlUploadStart > codeqlAnalyzeStart); + + var codeqlAnalyzeSection = codeqlWorkflow[codeqlAnalyzeStart..codeqlUploadStart]; + var codeqlUploadSection = codeqlWorkflow[codeqlUploadStart..]; + Assert.DoesNotContain("security-events: write", codeqlAnalyzeSection, StringComparison.Ordinal); + Assert.Contains("security-events: read", codeqlAnalyzeSection, StringComparison.Ordinal); + Assert.Contains("upload: never", codeqlAnalyzeSection, StringComparison.Ordinal); + Assert.Contains("upload-database: false", codeqlAnalyzeSection, StringComparison.Ordinal); + Assert.Contains("post-processed-sarif-path: codeql-results", codeqlAnalyzeSection, StringComparison.Ordinal); + Assert.Contains( + "if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)", + codeqlUploadSection, + StringComparison.Ordinal); + Assert.Contains("security-events: write", codeqlUploadSection, StringComparison.Ordinal); + Assert.Contains( + "category: \".github/workflows/codeql.yml:analyze/language:${{ matrix.language }}/\"", + codeqlUploadSection, + StringComparison.Ordinal); + + Assert.Contains("permissions:\n contents: read", releaseWorkflow, StringComparison.Ordinal); + Assert.Single( + Regex.Matches(releaseWorkflow, @"^\s+contents: write\s*$", RegexOptions.Multiline)); + Assert.Single( + Regex.Matches(releaseWorkflow, @"^\s+packages: write\s*$", RegexOptions.Multiline)); + } + /// /// Verifies that the CI workflow enforces higher per-class coverage thresholds for core diff logic. /// CI ワークフローがコア差分ロジックに対してクラス単位の高い閾値を強制していることを検証します。 @@ -601,7 +756,10 @@ public void DotNetWorkflow_MutationTestingPublishesSummaryArtifactsAndPrComment( Assert.Contains("Post mutation summary to job summary", workflow, StringComparison.Ordinal); Assert.Contains("Post mutation summary to pull request", workflow, StringComparison.Ordinal); Assert.Contains("continue-on-error: true", workflow, StringComparison.Ordinal); - Assert.Contains("actions/github-script@v7", workflow, StringComparison.Ordinal); + Assert.Contains( + "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0", + workflow, + StringComparison.Ordinal); Assert.Contains("issues: write", workflow, StringComparison.Ordinal); Assert.Contains("require('./scripts/update-mutation-pr-comment.js')", workflow, StringComparison.Ordinal); Assert.Contains("upsertMutationSummaryComment", workflow, StringComparison.Ordinal);