From c08466d1dc480039f0ee962849eec86bc8f05fdf Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sun, 23 Aug 2026 01:22:42 +0500 Subject: [PATCH 1/8] ci: add targeted diagnostics and runner cleanup --- .github/workflows/ci.yml | 117 ++- .github/workflows/codeql.yml | 4 +- .github/workflows/dependency-review.yml | 4 +- .../workflows/reusable-release-validation.yml | 98 ++- docs/contributing.md | 70 ++ .../Commands/CommandLineParser.cs | 14 +- .../Consumers/ConsumerScenarioRunner.cs | 40 +- eng/SmartPipe.RepositoryChecks/Program.cs | 2 +- eng/runner/install-runner.ps1 | 87 +++ eng/runner/monitor-pr.ps1 | 145 ++++ eng/runner/post-job-cleanup.ps1 | 63 ++ eng/runner/runner-safety.ps1 | 731 ++++++++++++++++++ eng/runner/uninstall-runner.ps1 | 54 ++ eng/tests/runner-contract.Tests.ps1 | 316 ++++++++ eng/tests/workflow-contract.Tests.ps1 | 6 + eng/tests/workflow_contract_tests.py | 278 +++++-- .../Commands/CommandLineParserTests.cs | 76 ++ .../Consumers/ConsumerScenarioRunnerTests.cs | 66 ++ 18 files changed, 2049 insertions(+), 122 deletions(-) create mode 100644 eng/runner/install-runner.ps1 create mode 100644 eng/runner/monitor-pr.ps1 create mode 100644 eng/runner/post-job-cleanup.ps1 create mode 100644 eng/runner/runner-safety.ps1 create mode 100644 eng/runner/uninstall-runner.ps1 create mode 100644 eng/tests/runner-contract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f7e9f5..19fe5fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,22 @@ name: CI on: workflow_dispatch: + inputs: + diagnostic-sha: + description: Exact 40-character commit SHA for a single-consumer diagnostic + required: false + type: string + default: '' + diagnostic-scenario: + description: Exact consumer scenario ID for a single-consumer diagnostic + required: false + type: string + default: '' + diagnostic-repeat: + description: Number of diagnostic runs (1-5) + required: false + type: string + default: '' push: branches: [ main, upd, release/2.2.0 ] pull_request: @@ -15,17 +31,17 @@ env: jobs: validation: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) uses: ./.github/workflows/reusable-release-validation.yml permissions: contents: read with: - runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64"]' || '["ubuntu-latest"]' }} + runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' || '["ubuntu-latest"]' }} hosting-integration: name: Hosting integration (${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64"]') || matrix.os }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ matrix.os == 'self-hosted' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || matrix.os }} timeout-minutes: 20 strategy: fail-fast: false @@ -51,8 +67,8 @@ jobs: run: dotnet test --project tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --configuration Release --no-build --filter-class SmartPipe.Extensions.Hosting.Tests.Integration.GenericHostIntegrationTests --minimum-expected-tests 1 json-file-windows: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -107,8 +123,8 @@ jobs: baseline-contract-windows: name: Baseline contract (Windows) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'windows-latest' }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && inputs.diagnostic-repeat == '')) + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'windows-latest' }} timeout-minutes: 20 steps: @@ -137,11 +153,94 @@ jobs: - name: Verify 2.1.2 baseline offline run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity + diagnostic-consumer: + name: Diagnostic consumer (${{ inputs.diagnostic-scenario }}) + if: github.event_name == 'workflow_dispatch' && (inputs.diagnostic-sha != '' || inputs.diagnostic-scenario != '' || inputs.diagnostic-repeat != '') + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] + timeout-minutes: 45 + steps: + - name: Validate diagnostic inputs + shell: pwsh + env: + DIAGNOSTIC_SHA: ${{ inputs.diagnostic-sha }} + DIAGNOSTIC_SCENARIO: ${{ inputs.diagnostic-scenario }} + DIAGNOSTIC_REPEAT: ${{ inputs.diagnostic-repeat }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:DIAGNOSTIC_SHA -notmatch '^[0-9a-f]{40}$') { throw 'diagnostic-sha must be exactly 40 lowercase hexadecimal characters.' } + if ($env:DIAGNOSTIC_SCENARIO -notmatch '^[a-z0-9-]+$') { throw 'diagnostic-scenario must contain lowercase letters, digits, or hyphens.' } + if ($env:DIAGNOSTIC_REPEAT -notmatch '^[1-5]$') { throw 'diagnostic-repeat must be an integer from 1 through 5.' } + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.diagnostic-sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Verify exact diagnostic checkout + shell: pwsh + env: + DIAGNOSTIC_SHA: ${{ inputs.diagnostic-sha }} + run: | + $ErrorActionPreference = 'Stop' + $actual = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $actual -cne $env:DIAGNOSTIC_SHA) { throw "Checked out SHA '$actual' does not match the requested diagnostic SHA." } + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + - name: Restore locked + run: dotnet restore SmartPipe.Core.slnx --locked-mode + + - name: Build + run: dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror + + - name: Set package version + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packageVersion = (dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($packageVersion)) { throw 'Unable to determine the package version.' } + "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Pack packages from graph + shell: pwsh + run: > + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj + --configuration Release --no-build -- pack-packages + --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" + --output artifacts/packages --manifest artifacts/packages/manifest.json + + - name: Run diagnostic consumer + shell: pwsh + env: + DIAGNOSTIC_SCENARIO: ${{ inputs.diagnostic-scenario }} + DIAGNOSTIC_REPEAT: ${{ inputs.diagnostic-repeat }} + run: | + $ErrorActionPreference = 'Stop' + $rows = [Collections.Generic.List[string]]::new() + $failed = $false + for ($pass = 1; $pass -le [int]$env:DIAGNOSTIC_REPEAT; $pass++) { + $output = (& dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --scenario $env:DIAGNOSTIC_SCENARIO --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" 2>&1 | Out-String).Trim() + $exitCode = $LASTEXITCODE + $snippet = ($output -replace '\r?\n', ' ').Trim() + if ($snippet.Length -gt 512) { $snippet = $snippet.Substring($snippet.Length - 512) } + $rows.Add("- pass ${pass}: exit=$exitCode; $snippet") + if ($exitCode -ne 0) { $failed = $true; break } + } + $summary = @('## Single-consumer diagnostic', '', "- scenario: $env:DIAGNOSTIC_SCENARIO", "- repeat requested: $env:DIAGNOSTIC_REPEAT") + $rows + $summaryText = ($summary -join [Environment]::NewLine) + if ($summaryText.Length -gt 8192) { $summaryText = $summaryText.Substring(0, 8192) + [Environment]::NewLine + '... summary truncated ...' } + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $summaryText + if ($failed) { exit 1 } + cleanup-self-hosted: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [validation, hosting-integration, json-file-windows, baseline-contract-windows] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e44a0e..82ade7a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,7 +18,7 @@ env: jobs: analyze: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64"]') || 'ubuntu-latest' }} + runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'ubuntu-latest' }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -47,7 +47,7 @@ jobs: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [analyze] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f274c32..c9483ac 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ permissions: jobs: dependency-review: if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -24,7 +24,7 @@ jobs: name: Cleanup self-hosted workspace if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: [dependency-review] - runs-on: [self-hosted, Windows, X64] + runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] steps: - name: Cleanup generated outputs shell: pwsh diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index fc358d6..6dbcf82 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -76,6 +76,49 @@ jobs: - name: Repository baseline contract tests run: dotnet test --project tests/SmartPipe.RepositoryChecks.Tests/SmartPipe.RepositoryChecks.Tests.csproj --configuration Release --no-build --minimum-expected-tests 1 + - name: Set package version + shell: pwsh + env: + REQUESTED_PACKAGE_VERSION: ${{ inputs.package-version }} + run: | + $packageVersion = $env:REQUESTED_PACKAGE_VERSION + if ([string]::IsNullOrWhiteSpace($packageVersion)) { + $packageVersion = dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Pack packages from graph + shell: pwsh + run: > + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj + --configuration Release --no-build -- pack-packages + --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" + --output artifacts/packages --manifest artifacts/packages/manifest.json + + - name: Provision 2.1.2 baseline packages + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- provision-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 + + - name: Verify 2.1.2 baseline offline + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity + + - name: Verify package graph current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-graph --mode current --packages artifacts/packages + + - name: Verify package metadata current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-metadata --package-directory artifacts/packages --mode current --report artifacts/packages/metadata-report.json + + - name: Verify package ownership current + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-ownership --baseline eng/baselines/2.1.2 --packages artifacts/packages --mode current + + - name: Verify release versions current + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$env:PACKAGE_VERSION" --package-directory artifacts/packages --mode current + + - name: Run current consumers + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" + - name: Core correctness regressions run: dotnet test --project tests/SmartPipe.Core.Tests/SmartPipe.Core.Tests.csproj --no-build -c Release --filter-query /[Category=CorrectnessRegression] --minimum-expected-tests 1 @@ -172,61 +215,6 @@ jobs: dotnet build benchmarks/SmartPipe.Benchmarks/SmartPipe.Benchmarks.csproj --no-restore -c Release -warnaserror if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Set package version - shell: pwsh - env: - REQUESTED_PACKAGE_VERSION: ${{ inputs.package-version }} - run: | - $packageVersion = $env:REQUESTED_PACKAGE_VERSION - if ([string]::IsNullOrWhiteSpace($packageVersion)) { - $packageVersion = dotnet msbuild src/SmartPipe.Core/SmartPipe.Core.csproj -getProperty:Version -nologo - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Pack packages from graph - shell: pwsh - run: > - dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj - --configuration Release --no-build -- pack-packages - --mode current --configuration Release --package-version "$env:PACKAGE_VERSION" - --output artifacts/packages --manifest artifacts/packages/manifest.json - - - name: Provision 2.1.2 baseline packages - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- provision-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 - - - name: Verify 2.1.2 baseline offline - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-baseline --repo-root . --manifest eng/baselines/2.1.2/manifest.json --packages-dir artifacts/baselines/2.1.2 --offline --mode integrity - - - name: Verify package graph current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-graph --mode current --packages artifacts/packages - - - name: Verify package metadata current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-metadata --package-directory artifacts/packages --mode current --report artifacts/packages/metadata-report.json - - - name: Verify package ownership current - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-package-ownership --baseline eng/baselines/2.1.2 --packages artifacts/packages --mode current - - - name: Verify release versions current - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- verify-release-version --tag "v$env:PACKAGE_VERSION" --package-directory artifacts/packages --mode current - - - name: Run current consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run Hosting consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category hosting --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run HealthChecks consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category health-checks --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - - name: Run OpenTelemetry consumers - shell: pwsh - run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build -- run-consumers --set current --category opentelemetry --package-directory artifacts/packages --package-version "$env:PACKAGE_VERSION" - - name: Vulnerable package scan shell: pwsh run: | diff --git a/docs/contributing.md b/docs/contributing.md index 1dac5e9..7d71acc 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -79,3 +79,73 @@ unbounded-memory symptom in progress notes. README examples are intentionally minimal. CI consumer smoke is the executable check for the public quick-start scenarios. + +## Dedicated Windows runner operations + +The same-repository Windows jobs use the exact labels +`self-hosted`, `Windows`, `X64`, and `smartpipe-cleanup-v1`. The installation +root is deliberately fixed at `C:\SmartPipe-Runner`; do not point the hook at a +developer checkout, `_tool`, the runner binaries, or a shared temporary root. + +Install or remove the repository-owned hook only while the runner is idle: + +```powershell +gh auth status +pwsh -NoProfile -File eng\runner\install-runner.ps1 +pwsh -NoProfile -File eng\runner\uninstall-runner.ps1 +``` + +The scripts resolve the exact runner name from `.runner` (`agentName`); an +optional `-RunnerName` is accepted only when it exactly matches that value. +They fail closed for missing or ambiguous configuration. The installer checks +the repository, queued/in-progress Actions runs, and remote runner state before +mutation. It writes only the hook's `.env` entry, copies the hook plus its +safety helper into the runner's `hooks` directory, registers exactly +`smartpipe-cleanup-v1` through the GitHub runner-label API while preserving +other labels, stops listeners tied to the exact root, launches one hidden +`run.cmd`, and waits for exactly one online, idle listener. Uninstall removes +only that custom label and the owned entry/copies, preserves unrelated labels +and `.env` lines, then performs the same bounded one-listener restart. A failed +operation reports recovery guidance; never convert the runner to a service as +part of this operation. + +The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout +remote, and canonicalizes every target beneath the dedicated runner root. It +removes the exact checkout and the known `SmartPipe.Core`, `SmartPipe-Core`, +`CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing targets are +successful. Any outside path, broad root, reparse point, unsafe repository, or +deletion error fails closed before removal; the existing workflow cleanup jobs +remain as defense in depth. + +For a compact, transition-only pull-request view: + +```powershell +pwsh -NoProfile -File eng\runner\monitor-pr.ps1 -PullRequest 123 -MaxPolls 120 +``` + +The monitor uses `gh pr view`, prints only a changed head/state/merge/check +summary, and stops at `MERGED`, `CLOSED`, or the poll bound. For each newly +failed head it retrieves one failed-run log, prints a bounded first-causal +slice, and removes its task-specific temporary log directory on exit. `-Once` +is useful for a single snapshot. It does not upload logs or alter GitHub state. + +The optional diagnostic dispatch runs one exact commit and one internal +consumer scenario without changing normal push or pull-request behavior: + +```powershell +gh workflow run ci.yml --repo MrFr3di/SmartPipe.Core --ref sp220/checkpoint-d ` + -f diagnostic-sha=0123456789abcdef0123456789abcdef01234567 ` + -f diagnostic-scenario=dependency-injection-nativeaot ` + -f diagnostic-repeat=1 +``` + +The SHA must be 40 lowercase hexadecimal characters, the scenario must use +lowercase letters, digits, and hyphens, and repeat must be `1` through `5`. +The job restores, builds, and packs once, then reports bounded run snippets in +the step summary without artifacts. Normal jobs run when all three inputs are +empty. + +If rollout must be reverted, stop the idle listener, run the uninstaller, +restart the listener, and revert the workflow change with a normal commit. +Do not delete the runner root or use `git clean`; safe cleanup is intentionally +recoverable and scoped to the exact approved boundaries. diff --git a/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs b/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs index 90803ca..864724e 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/CommandLineParser.cs @@ -92,7 +92,8 @@ internal sealed record RunConsumersCommandOptions( string PackageDirectory, string PackageVersion, string ManifestPath, - string? Category) : RepositoryCheckCommand(RepositoryRoot); + string? Category, + string? Scenario) : RepositoryCheckCommand(RepositoryRoot); internal sealed record PackPackagesOptions(string RepositoryRoot, PackageGraphMode Mode, string Configuration, string PackageVersion, string OutputDirectory, string ManifestPath) : RepositoryCheckCommand(RepositoryRoot); internal sealed class CommandLineException(string message) : Exception(message); @@ -192,7 +193,7 @@ private static PackPackagesOptions ParsePackPackages(ReadOnlySpan args) private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan args) { - string? root = null; string? set = null; string? packages = null; string? version = null; string manifest = "eng/consumer-scenarios.json"; string? category = null; + string? root = null; string? set = null; string? packages = null; string? version = null; string manifest = "eng/consumer-scenarios.json"; string? category = null; string? scenario = null; var seen = new HashSet(StringComparer.Ordinal); for (var i = 0; i < args.Length; i += 2) { @@ -206,6 +207,7 @@ private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan case "--package-version": version = args[i + 1]; break; case "--manifest": manifest = args[i + 1]; break; case "--category": category = args[i + 1]; break; + case "--scenario": scenario = args[i + 1]; break; default: throw new CommandLineException($"Unknown run-consumers option '{args[i]}'."); } } @@ -218,7 +220,13 @@ private static RunConsumersCommandOptions ParseRunConsumers(ReadOnlySpan && (category.Length == 0 || category.Any(character => character is not (>= 'a' and <= 'z' or >= '0' and <= '9' or '-')))) throw new CommandLineException("Option '--category' must contain lowercase letters, digits, or hyphens."); - return new(root, set, ResolveWithinRoot(root, packages, "--package-directory"), version, Path.GetRelativePath(root, resolvedManifest).Replace('\\', '/'), category); + if (scenario is not null + && (scenario.Length == 0 + || scenario.Any(character => character is not (>= 'a' and <= 'z' or >= '0' and <= '9' or '-')))) + throw new CommandLineException("Option '--scenario' must contain lowercase letters, digits, or hyphens."); + if (category is not null && scenario is not null) + throw new CommandLineException("Options '--category' and '--scenario' are mutually exclusive."); + return new(root, set, ResolveWithinRoot(root, packages, "--package-directory"), version, Path.GetRelativePath(root, resolvedManifest).Replace('\\', '/'), category, scenario); } private static ScaffoldPackageOptions ParseScaffoldPackage(ReadOnlySpan args) diff --git a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs index 87e8a25..e74b690 100644 --- a/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs +++ b/eng/SmartPipe.RepositoryChecks/Consumers/ConsumerScenarioRunner.cs @@ -19,7 +19,8 @@ internal sealed record RunConsumersOptions( string PackageDirectory, string PackageVersion, string ManifestPath, - string? Category = null); + string? Category = null, + string? Scenario = null); internal sealed class ConsumerScenarioRunner(DotNetProcessRunner? processRunner = null) { @@ -32,9 +33,16 @@ public async Task> RunAsync(RunConsumersOp var document = await new ConsumerScenarioLoader().LoadAsync(options.RepositoryRoot, options.ManifestPath, graph, ct).ConfigureAwait(false); var scenarios = document.Scenarios .Where(scenario => scenario.Set == options.Set - && (options.Category is null || scenario.Category == options.Category)) + && (options.Category is null || scenario.Category == options.Category) + && (options.Scenario is null || scenario.Id == options.Scenario)) .ToArray(); - if (scenarios.Length == 0) throw new ConsumerScenarioException("SPCONS010", $"Consumer set '{options.Set}' is empty."); + if (scenarios.Length == 0) + { + var selection = options.Scenario is null + ? $"Consumer set '{options.Set}' is empty." + : $"Consumer scenario '{options.Scenario}' is unknown."; + throw new ConsumerScenarioException("SPCONS010", selection); + } var centralPackages = await new CentralPackageVersionReader().VerifyAsync( options.RepositoryRoot, CentralPackageValidationMode.Current, @@ -104,6 +112,8 @@ private async Task RunScenarioAsync( var locked = restore.ToList(); locked.Remove("--use-lock-file"); locked.Add("--locked-mode"); await RunRequiredAsync("dotnet", locked, source, logs, options.RepositoryRoot, scenario.Timeout, events, ct).ConfigureAwait(false); } + if (scenario.Mode == ConsumerMode.PublishNativeAot) + ValidateNativeAotLibraryPaths(packages); string outputDirectory; IReadOnlyList? publishArguments = null; @@ -584,6 +594,30 @@ internal static void CopyTemplateDirectory(string root, string templatePath, str } private static string RuntimeIdentifier() => OperatingSystem.IsWindows() ? "win-x64" : OperatingSystem.IsLinux() ? "linux-x64" : OperatingSystem.IsMacOS() ? "osx-x64" : throw new ConsumerScenarioException("SPCONS018", "NativeAOT/trim scenario is unsupported on this OS."); + + internal static void ValidateNativeAotLibraryPaths(string packageDirectory, bool? isWindows = null) + { + if (!(isWindows ?? OperatingSystem.IsWindows())) return; + + var root = Path.GetFullPath(packageDirectory); + if (!Directory.Exists(root)) return; + foreach (var path in Directory.EnumerateFiles(root, "*.lib", SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + var relative = Path.GetRelativePath(root, fullPath); + if (Path.IsPathRooted(relative) + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new ConsumerScenarioException("SPCONS025", "NativeAOT library path escapes the scenario package cache."); + + var effectiveLength = fullPath.Length + 1; + if (effectiveLength >= 260) + throw new ConsumerScenarioException( + "SPCONS025", + $"NativeAOT library path is too long ({effectiveLength} characters including the terminating NUL): {relative.Replace('\\', '/')}"); + } + } + internal static void ValidateBinaryCompatibilityPhases(IReadOnlyList events, int expectedReplacements) { var builds = events.Select((item, index) => (item, index)).Where(x => x.item.Phase == "process" && x.item.Command.Contains(" build ", StringComparison.Ordinal)).ToArray(); diff --git a/eng/SmartPipe.RepositoryChecks/Program.cs b/eng/SmartPipe.RepositoryChecks/Program.cs index a05029e..3b3775c 100644 --- a/eng/SmartPipe.RepositoryChecks/Program.cs +++ b/eng/SmartPipe.RepositoryChecks/Program.cs @@ -283,7 +283,7 @@ internal static async Task Main(string[] args) return ExitCodes.Success; case RunConsumersCommandOptions consumers: - var consumerResults = await new ConsumerScenarioRunner().RunAsync(new(consumers.RepositoryRoot, consumers.Set, consumers.PackageDirectory, consumers.PackageVersion, consumers.ManifestPath, consumers.Category), cancellation.Token).ConfigureAwait(false); + var consumerResults = await new ConsumerScenarioRunner().RunAsync(new(consumers.RepositoryRoot, consumers.Set, consumers.PackageDirectory, consumers.PackageVersion, consumers.ManifestPath, consumers.Category, consumers.Scenario), cancellation.Token).ConfigureAwait(false); foreach (var consumer in consumerResults) Console.WriteLine($"SP220_CONSUMER_OK scenario={consumer.Scenario} durationMs={consumer.DurationMs} dependencies={consumer.ObservedSmartPipeDependencies.Count}"); Console.WriteLine($"SP220_CONSUMERS_OK scenarios={consumerResults.Count} set={consumers.Set}"); return ExitCodes.Success; diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 new file mode 100644 index 0000000..bc07c67 --- /dev/null +++ b/eng/runner/install-runner.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $RunnerName = '', + [string] $GhPath = 'gh', + [string] $ListenerFixturePath = '', + [int] $ListenerTimeoutSeconds = 60, + [switch] $SkipRemoteCheck, + [switch] $SkipListenerReady, + [switch] $AllowTestRoot, + [switch] $Uninstall +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + throw "Dedicated runner root is missing: $runner" + } + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName + if ($SkipRemoteCheck) { + throw 'Remote idle checks cannot be skipped because runner label registration is required. Recovery: no runner files or labels were changed.' + } + + Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath + $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + + if ($Uninstall) { + $environmentPath = Join-Path $runner '.env' + Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath + $hookDirectory = Join-Path $runner 'hooks' + foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + $path = Join-Path $hookDirectory $name + if (Test-Path -LiteralPath $path) { + Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." + exit 0 + } + + $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'post-job-cleanup.ps1') + $safetySource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'runner-safety.ps1') + if (-not (Test-Path -LiteralPath $hookSource -PathType Leaf) -or + -not (Test-Path -LiteralPath $safetySource -PathType Leaf)) { + throw 'Runner hook sources are missing.' + } + + $hookDirectory = Join-Path $runner 'hooks' + if (-not (Test-Path -LiteralPath $hookDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $hookDirectory -Force | Out-Null + } + Assert-SmartPipeNoReparsePath -Path $hookDirectory -Boundary $runner + + $hookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + $safetyDestination = Join-Path $hookDirectory 'runner-safety.ps1' + Copy-Item -LiteralPath $hookSource -Destination $hookDestination -Force + Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force + + $environmentPath = Join-Path $runner '.env' + Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination + Add-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Installed SmartPipe hook and label under $runner with one online idle listener." +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; existing runner labels are never intentionally removed." + exit 1 +} diff --git a/eng/runner/monitor-pr.ps1 b/eng/runner/monitor-pr.ps1 new file mode 100644 index 0000000..bad8378 --- /dev/null +++ b/eng/runner/monitor-pr.ps1 @@ -0,0 +1,145 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [int] $PullRequest, + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $GhPath = 'gh', + [int] $PollSeconds = 60, + [int] $MaxPolls = 0, + [switch] $Once +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +function Get-SmartPipeCheckSummary { + param( + [Parameter(Mandatory = $true)] + [object] $Checks + ) + + $parts = [Collections.Generic.List[string]]::new() + foreach ($check in @($Checks)) { + if ($null -eq $check) { + continue + } + $properties = @($check.PSObject.Properties.Name) + $name = if ('name' -in $properties -and $null -ne $check.name) { [string]$check.name } elseif ('context' -in $properties -and $null -ne $check.context) { [string]$check.context } else { 'check' } + $state = if ('conclusion' -in $properties -and [string]$check.conclusion) { [string]$check.conclusion } elseif ('status' -in $properties -and $null -ne $check.status) { [string]$check.status } else { 'pending' } + $parts.Add("$name=$state") + } + + $summary = $parts -join ',' + if ($summary.Length -gt 512) { + return $summary.Substring(0, 512) + '...' + } + + return $summary +} + +function Write-SmartPipeFirstFailure { + param( + [Parameter(Mandatory = $true)] [string] $Head, + [Parameter(Mandatory = $true)] [string] $TemporaryRoot + ) + + $global:LASTEXITCODE = 0 + $runJson = & $GhPath run list --repo $Repository --commit $Head --status failure --limit 1 --json databaseId 2>&1 + if ($global:LASTEXITCODE -ne 0) { + Write-Output 'PR diagnostic: unable to list the failed workflow run.' + return + } + + $runs = @(($runJson -join [Environment]::NewLine) | ConvertFrom-Json) + if ($runs.Count -eq 0) { + Write-Output 'PR diagnostic: no failed workflow run is available yet.' + return + } + + $runId = [string]$runs[0].databaseId + if ($runId -notmatch '^[0-9]+$') { + Write-Output 'PR diagnostic: failed workflow run id is invalid.' + return + } + + $global:LASTEXITCODE = 0 + $failedLog = @(& $GhPath run view $runId --repo $Repository --log-failed 2>&1 | ForEach-Object { [string]$_ }) + $logExitCode = $global:LASTEXITCODE + $logPath = Join-Path $TemporaryRoot "failed-$Head-$runId.log" + [IO.File]::WriteAllLines($logPath, $failedLog) + if ($logExitCode -ne 0) { + Write-Output 'PR diagnostic: failed-step log retrieval was incomplete.' + return + } + + $index = -1 + for ($line = 0; $line -lt $failedLog.Count; $line++) { + if ($failedLog[$line] -match '(?i)(error|exception|failed|NU[0-9]{4}|SP[A-Z]+[0-9]{3})') { + $index = $line + break + } + } + if ($index -lt 0) { $index = 0 } + $last = [Math]::Min($failedLog.Count - 1, $index + 4) + $slice = if ($failedLog.Count -eq 0) { 'no failed-step output' } else { ($failedLog[$index..$last] -join ' | ').Trim() } + if ($slice.Length -gt 1024) { $slice = $slice.Substring(0, 1024) + '...' } + Write-Output "PR diagnostic: first causal slice: $slice" +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-pr-monitor-$PID-$([Guid]::NewGuid().ToString('N'))" +try { + Assert-SmartPipeRepository -Repository $Repository + if ($PullRequest -lt 1) { + throw 'PullRequest must be positive.' + } + if ($PollSeconds -lt 1) { + throw 'PollSeconds must be positive.' + } + if ($MaxPolls -lt 0) { + throw 'MaxPolls cannot be negative.' + } + + New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null + $previous = $null + $diagnosedHead = '' + $poll = 0 + while ($true) { + $LASTEXITCODE = 0 + $json = & $GhPath pr view $PullRequest --repo $Repository --json state,mergeStateStatus,headRefOid,statusCheckRollup 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "gh pr view failed: $($json -join ' ')" + } + + $view = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $state = [string]$view.state + $mergeState = [string]$view.mergeStateStatus + $head = [string]$view.headRefOid + $checks = Get-SmartPipeCheckSummary -Checks $view.statusCheckRollup + $signature = "$state|$mergeState|$head|$checks" + if ($signature -ne $previous) { + Write-Output "PR #$PullRequest transition: state=$state merge=$mergeState head=$head checks=$checks" + $previous = $signature + } + if ($head -ne $diagnosedHead -and $checks -match '(?i)=(FAILURE|CANCELLED|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE)') { + Write-SmartPipeFirstFailure -Head $head -TemporaryRoot $temporaryRoot + $diagnosedHead = $head + } + + $poll++ + if ($state -in @('MERGED', 'CLOSED') -or $Once -or ($MaxPolls -gt 0 -and $poll -ge $MaxPolls)) { + break + } + + Start-Sleep -Seconds $PollSeconds + } +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message $errorText + exit 1 +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/post-job-cleanup.ps1 new file mode 100644 index 0000000..aad347a --- /dev/null +++ b/eng/runner/post-job-cleanup.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $WorkspaceRoot = $env:GITHUB_WORKSPACE, + [string] $TempRoot = $env:RUNNER_TEMP, + [string] $Repository = $env:GITHUB_REPOSITORY, + [switch] $AllowTestRoot +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + throw "Dedicated runner root is missing: $runner" + } + + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + + if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { + throw 'GITHUB_WORKSPACE is required.' + } + + $workspace = Get-SmartPipeFullPath -Path $WorkspaceRoot + if (-not (Test-SmartPipeContainedPath -Path $workspace -Boundary $runner)) { + throw "Workspace is outside the dedicated runner root: $workspace" + } + + if (Test-Path -LiteralPath $workspace -PathType Container) { + Assert-SmartPipeWorkspaceRepository -Workspace $workspace + [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + } + else { + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + } + + if (-not [string]::IsNullOrWhiteSpace($TempRoot)) { + $temp = Get-SmartPipeFullPath -Path $TempRoot + if (-not (Test-SmartPipeContainedPath -Path $temp -Boundary $runner)) { + throw "Runner temp is outside the dedicated runner root: $temp" + } + + if (Test-Path -LiteralPath $temp -PathType Container) { + Assert-SmartPipeNoReparsePath -Path $temp -Boundary $runner + foreach ($name in @('SmartPipe.Core', 'SmartPipe-Core', 'CodeQL', 'codeql')) { + $target = Join-Path $temp $name + [void](Remove-SmartPipeCleanupTarget -Path $target -Boundary $temp) + } + } + } + + Write-Output 'SmartPipe post-job cleanup completed.' +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message $errorText + exit 1 +} diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 new file mode 100644 index 0000000..6e7685e --- /dev/null +++ b/eng/runner/runner-safety.ps1 @@ -0,0 +1,731 @@ +Set-StrictMode -Version Latest + +$script:SmartPipeRunnerDefaultRoot = 'C:\SmartPipe-Runner' +$script:SmartPipeRunnerRepository = 'MrFr3di/SmartPipe-Core' +$script:SmartPipeRunnerLabel = 'smartpipe-cleanup-v1' + +function Get-SmartPipeFullPath { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw 'A path is required.' + } + + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } + catch { + throw "Invalid path: $Path" + } + + if ($fullPath.Length -gt 3) { + return $fullPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + } + + return $fullPath +} + +function Test-SmartPipeSamePath { + param( + [Parameter(Mandatory = $true)] + [string] $Left, + + [Parameter(Mandatory = $true)] + [string] $Right + ) + + return [string]::Equals( + (Get-SmartPipeFullPath -Path $Left), + (Get-SmartPipeFullPath -Path $Right), + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-SmartPipeContainedPath { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if ($AllowBoundary -and (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath)) { + return $true + } + + $prefix = "$boundaryPath$([IO.Path]::DirectorySeparatorChar)" + return $candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) +} + +function Assert-SmartPipeNoReparsePath { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary)) { + throw "Path is outside the approved boundary: $candidate" + } + + $current = $candidate + while ($true) { + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Reparse point is not an approved cleanup target: $current" + } + } + + if (Test-SmartPipeSamePath -Left $current -Right $boundaryPath) { + break + } + + $parent = Split-Path -Path $current -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or (Test-SmartPipeSamePath -Left $parent -Right $current)) { + throw "Could not prove path containment: $candidate" + } + + $current = Get-SmartPipeFullPath -Path $parent + if (-not (Test-SmartPipeContainedPath -Path $current -Boundary $boundaryPath -AllowBoundary)) { + throw "Path escaped the approved boundary: $candidate" + } + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + return + } + + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($candidate) + while ($pending.Count -gt 0) { + $directory = $pending.Pop() + foreach ($child in Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Reparse point is not an approved cleanup target: $($child.FullName)" + } + + if ($child.PSIsContainer) { + $pending.Push($child.FullName) + } + } + } +} + +function Assert-SmartPipeCleanupTarget { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Get-SmartPipeFullPath -Path $Path + $boundaryPath = Get-SmartPipeFullPath -Path $Boundary + if (Test-SmartPipeSamePath -Left $candidate -Right $boundaryPath) { + throw "Cleanup target is the approved boundary itself: $candidate" + } + if (-not (Test-SmartPipeContainedPath -Path $candidate -Boundary $boundaryPath -AllowBoundary:$AllowBoundary)) { + throw "Cleanup target is outside the approved boundary: $candidate" + } + + $runnerLeaf = Split-Path -Path $candidate -Leaf + if ($runnerLeaf -in @('_tool', '_work', 'bin', 'Runner', 'externals')) { + throw "Cleanup target is too broad or protected: $candidate" + } + + $runnerRoot = Get-SmartPipeFullPath -Path $script:SmartPipeRunnerDefaultRoot + if (Test-SmartPipeSamePath -Left $candidate -Right $runnerRoot) { + throw 'The dedicated runner root is never a cleanup target.' + } + + Assert-SmartPipeNoReparsePath -Path $candidate -Boundary $Boundary + return $candidate +} + +function Remove-SmartPipeCleanupTarget { + param( + [Parameter(Mandatory = $true)] + [string] $Path, + + [Parameter(Mandatory = $true)] + [string] $Boundary, + + [switch] $AllowBoundary + ) + + $candidate = Assert-SmartPipeCleanupTarget -Path $Path -Boundary $Boundary -AllowBoundary:$AllowBoundary + if (-not (Test-Path -LiteralPath $candidate)) { + return $false + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + throw "Cleanup target is not a directory: $candidate" + } + + Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop + return $true +} + +function Assert-SmartPipeRepository { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Repository + ) + + if (-not [string]::Equals($Repository, $script:SmartPipeRunnerRepository, [StringComparison]::OrdinalIgnoreCase)) { + throw "Unexpected repository '$Repository'." + } +} + +function Resolve-SmartPipeRunnerName { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $RequestedName = '' + ) + + $configPath = Join-Path $Root '.runner' + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Runner configuration is missing: $configPath" + } + + try { + $config = Get-Content -LiteralPath $configPath -Raw -ErrorAction Stop | ConvertFrom-Json + $agentNameProperty = @($config.PSObject.Properties | Where-Object { $_.Name -eq 'agentName' }) + if ($agentNameProperty.Count -ne 1 -or $null -eq $agentNameProperty[0].Value -or + $agentNameProperty[0].Value -is [Array]) { + throw 'agentName is missing or ambiguous.' + } + $configuredName = [string]$agentNameProperty[0].Value + } + catch { + throw "Runner configuration is invalid: $configPath" + } + + if ([string]::IsNullOrWhiteSpace($configuredName)) { + throw "Runner configuration has no unambiguous agentName: $configPath" + } + if (-not [string]::IsNullOrWhiteSpace($RequestedName) -and + -not [string]::Equals($RequestedName, $configuredName, [StringComparison]::Ordinal)) { + throw "Requested runner name '$RequestedName' does not match .runner agentName '$configuredName'." + } + + return $configuredName +} + +function Assert-SmartPipeWorkspaceRepository { + param( + [Parameter(Mandatory = $true)] + [string] $Workspace + ) + + $gitPath = Join-Path $Workspace '.git' + if (-not (Test-Path -LiteralPath $gitPath)) { + throw "Workspace repository metadata is missing: $Workspace" + } + + $configPath = if (Test-Path -LiteralPath $gitPath -PathType Container) { + Join-Path $gitPath 'config' + } + else { + $gitPath + } + + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Workspace repository configuration is missing: $Workspace" + } + + $global:LASTEXITCODE = 0 + $gitOutput = & git -C $Workspace remote get-url origin 2>&1 + $gitExitCode = $global:LASTEXITCODE + if ($gitExitCode -eq 0) { + $urls = @($gitOutput | ForEach-Object { ([string]$_).Trim() } | Where-Object { $_ -ne '' }) + if ($urls.Count -ne 1) { + throw "Workspace origin remote is ambiguous: $Workspace" + } + + Assert-SmartPipeCanonicalRemote -Url $urls[0] -Workspace $Workspace + return + } + + # Test fixtures and worktrees without a usable git executable use the + # strict INI fallback. Comments never participate in URL selection. + $section = '' + $originUrls = [Collections.Generic.List[string]]::new() + foreach ($line in (Get-Content -LiteralPath $configPath -ErrorAction Stop)) { + $text = ([string]$line).Trim() + if ($text -eq '' -or $text.StartsWith('#') -or $text.StartsWith(';')) { + continue + } + + if ($text -match '^\[remote\s+"([^"]+)"\]$') { + $section = $Matches[1] + continue + } + + if ($text -match '^(?[A-Za-z][A-Za-z0-9-]*)\s*=\s*(?\S+)$') { + if ($section -eq 'origin' -and $Matches.key -eq 'url') { + [void]$originUrls.Add($Matches.value) + } + elseif ($section -eq 'origin' -and $Matches.key -notin @('fetch', 'pushurl', 'mirror', 'tagopt')) { + throw "Unsupported origin configuration entry: $Workspace" + } + continue + } + + throw "Invalid git remote configuration: $Workspace" + } + + if ($originUrls.Count -ne 1) { + throw "Workspace origin remote is missing or ambiguous: $Workspace" + } + + Assert-SmartPipeCanonicalRemote -Url $originUrls[0] -Workspace $Workspace +} + +function Assert-SmartPipeCanonicalRemote { + param( + [Parameter(Mandatory = $true)] + [string] $Url, + + [Parameter(Mandatory = $true)] + [string] $Workspace + ) + + $normalized = $Url.Trim() + if ($normalized -match '^(?i:https://github\.com/MrFr3di/SmartPipe-Core(?:\.git)?|git@github\.com:MrFr3di/SmartPipe-Core(?:\.git)?|ssh://git@github\.com/MrFr3di/SmartPipe-Core(?:\.git)?)$') { + return + } + + throw "Workspace origin remote is not MrFr3di/SmartPipe-Core: $Workspace" +} + +function Get-SmartPipeListenerProcesses { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + if (-not (Test-Path -LiteralPath $FixturePath -PathType Leaf)) { + return @() + } + + $text = (Get-Content -LiteralPath $FixturePath -Raw -ErrorAction Stop).Trim() + $count = 0 + if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { + throw "Invalid listener fixture state: $FixturePath" + } + + $fixtureListeners = [Collections.Generic.List[object]]::new() + for ($index = 1; $index -le $count; $index++) { + [void]$fixtureListeners.Add([pscustomobject]@{ + ProcessId = 0 + Name = 'Runner.Listener.fixture' + CommandLine = $Root + }) + } + return @($fixtureListeners) + } + + try { + $escapedRoot = [Regex]::Escape((Get-SmartPipeFullPath -Path $Root)) + return @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') -and + $_.CommandLine -match $escapedRoot + }) + } + catch { + if ($IsWindows) { + throw "Unable to inspect listener processes for $Root." + } + return @() + } +} + +function Stop-SmartPipeListenerProcesses { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '', + + [int] $TimeoutSeconds = 20 + ) + + if ($TimeoutSeconds -lt 1) { + throw 'Listener stop timeout must be positive.' + } + + $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + Set-Content -LiteralPath $FixturePath -Value '0' -NoNewline + return + } + + foreach ($listener in $listeners) { + if ([int]$listener.ProcessId -gt 0) { + Stop-Process -Id $listener.ProcessId -Force -ErrorAction Stop + } + } + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while (@(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath).Count -gt 0) { + if ([DateTime]::UtcNow -ge $deadline) { + throw "Runner listener did not stop within $TimeoutSeconds seconds: $Root" + } + Start-Sleep -Seconds 1 + } +} + +function Start-SmartPipeRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + $runCommand = Join-Path $Root 'run.cmd' + if (-not (Test-Path -LiteralPath $runCommand -PathType Leaf)) { + throw "Runner command is missing: $runCommand" + } + + Start-Process -FilePath $runCommand -WorkingDirectory $Root -WindowStyle Hidden | Out-Null + if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { + Set-Content -LiteralPath $FixturePath -Value '1' -NoNewline + } +} + +function Get-SmartPipeRemoteRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh' + ) + + $global:LASTEXITCODE = 0 + $json = & $GhPath api "repos/$Repository/actions/runners?per_page=100" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to query GitHub runner state: $($json -join ' ')" + } + + $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $runners = @($response.runners | Where-Object { $_.name -eq $RunnerName }) + if ($runners.Count -ne 1) { + throw "Expected exactly one GitHub runner named '$RunnerName'." + } + + return ,$runners[0] +} + +function Get-SmartPipeRunnerLabelNames { + param( + [Parameter(Mandatory = $true)] + [object] $Runner + ) + + $names = [Collections.Generic.List[string]]::new() + foreach ($label in @($Runner.labels)) { + if ($label -is [string]) { + $name = [string]$label + } + else { + $nameProperty = $label.PSObject.Properties['name'] + $name = if ($null -ne $nameProperty) { [string]$nameProperty.Value } else { '' } + } + if (-not [string]::IsNullOrWhiteSpace($name)) { + [void]$names.Add($name) + } + } + return $names.ToArray() +} + +function Add-SmartPipeRunnerLabel { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [object] $Runner, + + [string] $GhPath = 'gh' + ) + + $runnerId = [string]$Runner.id + if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { + throw 'GitHub runner id is missing or invalid; refusing label mutation.' + } + + $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) + $global:LASTEXITCODE = 0 + $json = & $GhPath api --method POST "repos/$Repository/actions/runners/$runnerId/labels" -f "labels[]=$script:SmartPipeRunnerLabel" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to add runner label '$script:SmartPipeRunnerLabel'. Existing labels were not intentionally removed." + } + + try { + $postResponse = ($json -join [Environment]::NewLine) | ConvertFrom-Json + $postLabels = @(Get-SmartPipeRunnerLabelNames -Runner $postResponse) + } + catch { + throw "GitHub runner label response was invalid: $($json -join ' '). Recovery: existing labels were not intentionally removed; inspect the runner before retrying." + } + if ($script:SmartPipeRunnerLabel -notin $postLabels) { + throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel' in the mutation response." + } + + $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath + $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) + if ($script:SmartPipeRunnerLabel -notin $after) { + throw "GitHub did not confirm runner label '$script:SmartPipeRunnerLabel'." + } + foreach ($label in $before) { + if ($label -notin $after) { + throw "Adding runner label removed existing label '$label'; refusing to continue." + } + } +} + +function Remove-SmartPipeRunnerLabel { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [object] $Runner, + + [string] $GhPath = 'gh' + ) + + $runnerId = [string]$Runner.id + if ([string]::IsNullOrWhiteSpace($runnerId) -or $runnerId -notmatch '^[0-9]+$') { + throw 'GitHub runner id is missing or invalid; refusing label mutation.' + } + + $before = @(Get-SmartPipeRunnerLabelNames -Runner $Runner) + if ($script:SmartPipeRunnerLabel -in $before) { + $global:LASTEXITCODE = 0 + $null = & $GhPath api --method DELETE "repos/$Repository/actions/runners/$runnerId/labels/$script:SmartPipeRunnerLabel" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to remove runner label '$script:SmartPipeRunnerLabel'." + } + } + + $afterRunner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName ([string]$Runner.name) -GhPath $GhPath + $after = @(Get-SmartPipeRunnerLabelNames -Runner $afterRunner) + if ($script:SmartPipeRunnerLabel -in $after) { + throw "GitHub still reports runner label '$script:SmartPipeRunnerLabel' after removal." + } + foreach ($label in ($before | Where-Object { $_ -ne $script:SmartPipeRunnerLabel })) { + if ($label -notin $after) { + throw "Removing runner label removed unrelated label '$label'; refusing to continue." + } + } +} + +function Assert-SmartPipeActionsRunsIdle { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [string] $GhPath = 'gh' + ) + + foreach ($status in @('queued', 'in_progress')) { + $global:LASTEXITCODE = 0 + $json = & $GhPath api "repos/$Repository/actions/runs?status=$status&per_page=100" 2>&1 + $ghExitCode = $global:LASTEXITCODE + if ($ghExitCode -ne 0) { + throw "Unable to query $status GitHub Actions runs: $($json -join ' ')" + } + + $response = ($json -join [Environment]::NewLine) | ConvertFrom-Json + if (@($response.workflow_runs).Count -gt 0) { + throw "GitHub Actions has $status runs; refusing runner mutation." + } + } +} + +function Assert-SmartPipeRemoteRunnerIdle { + param( + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh' + ) + + $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath + if ($runner.busy -eq $true) { + throw "Runner '$RunnerName' is busy." + } + return ,$runner +} + +function Wait-SmartPipeRunnerReady { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh', + [string] $FixturePath = '', + [int] $TimeoutSeconds = 60 + ) + + if ($TimeoutSeconds -lt 1) { + throw 'Runner readiness timeout must be positive.' + } + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + $listeners = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) + if ($listeners.Count -gt 1) { + throw "More than one runner listener is tied to $Root." + } + + $runner = Get-SmartPipeRemoteRunner -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath + if ($listeners.Count -eq 1 -and [string]$runner.status -eq 'online' -and $runner.busy -eq $false) { + return + } + + if ([DateTime]::UtcNow -ge $deadline) { + throw "Runner '$RunnerName' did not become online and idle with one listener within $TimeoutSeconds seconds." + } + Start-Sleep -Seconds 1 + } +} + +function Restart-SmartPipeRunner { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [Parameter(Mandatory = $true)] + [string] $Repository, + + [Parameter(Mandatory = $true)] + [string] $RunnerName, + + [string] $GhPath = 'gh', + [string] $FixturePath = '', + [int] $TimeoutSeconds = 60 + ) + + Stop-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath + Start-SmartPipeRunner -Root $Root -FixturePath $FixturePath + Wait-SmartPipeRunnerReady -Root $Root -Repository $Repository -RunnerName $RunnerName -GhPath $GhPath -FixturePath $FixturePath -TimeoutSeconds $TimeoutSeconds +} + +function Get-SmartPipeOwnedEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath + ) + + if (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf) { + $raw = Get-Content -LiteralPath $EnvironmentPath -Raw -ErrorAction Stop + if ([string]::IsNullOrEmpty($raw)) { + return ,([Collections.Generic.List[string]]::new()) + } + + $lines = [Collections.Generic.List[string]]::new() + $rawLines = @($raw -split '\r?\n') + if ($rawLines.Count -gt 0 -and $rawLines[$rawLines.Count - 1] -eq '') { + $rawLines = if ($rawLines.Count -eq 1) { @() } else { $rawLines[0..($rawLines.Count - 2)] } + } + foreach ($line in $rawLines) { + [void]$lines.Add([string]$line) + } + return ,$lines + } + + return ,([Collections.Generic.List[string]]::new()) +} + +function Write-SmartPipeEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath, + + [Parameter(Mandatory = $true)] + [string] $HookPath + ) + + $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath + $owned = @{ + 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + } + + foreach ($key in $owned.Keys) { + for ($index = $lines.Count - 1; $index -ge 0; $index--) { + if ($lines[$index] -match "^\s*${key}=") { + $lines.RemoveAt($index) + } + } + + $lines.Add("$key=$($owned[$key])") + } + + $temporaryPath = "$EnvironmentPath.smartpipe.tmp" + [IO.File]::WriteAllText($temporaryPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $EnvironmentPath -Force +} + +function Remove-SmartPipeEnvironment { + param( + [Parameter(Mandatory = $true)] + [string] $EnvironmentPath + ) + + if (-not (Test-Path -LiteralPath $EnvironmentPath -PathType Leaf)) { + return + } + + $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED') + for ($index = $lines.Count - 1; $index -ge 0; $index--) { + foreach ($key in $ownedKeys) { + if ($lines[$index] -match "^\s*${key}=") { + $lines.RemoveAt($index) + break + } + } + } + + [IO.File]::WriteAllText($EnvironmentPath, (($lines -join [Environment]::NewLine) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) +} diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 new file mode 100644 index 0000000..fc29aad --- /dev/null +++ b/eng/runner/uninstall-runner.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string] $RunnerRoot = 'C:\SmartPipe-Runner', + [string] $Repository = 'MrFr3di/SmartPipe-Core', + [string] $RunnerName = '', + [string] $GhPath = 'gh', + [string] $ListenerFixturePath = '', + [int] $ListenerTimeoutSeconds = 60, + [switch] $SkipListenerReady, + [switch] $AllowTestRoot +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'runner-safety.ps1') + +try { + Assert-SmartPipeRepository -Repository $Repository + $runner = Get-SmartPipeFullPath -Path $RunnerRoot + if (-not $AllowTestRoot -and -not (Test-SmartPipeSamePath -Left $runner -Right $script:SmartPipeRunnerDefaultRoot)) { + throw "The production runner root must be $script:SmartPipeRunnerDefaultRoot." + } + + if (-not (Test-Path -LiteralPath $runner -PathType Container)) { + Write-Output "Runner root is already absent: $runner" + exit 0 + } + Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName + Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath + $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + + $environmentPath = Join-Path $runner '.env' + Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath + + $hookDirectory = Join-Path $runner 'hooks' + foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + $path = Join-Path $hookDirectory $name + if (Test-Path -LiteralPath $path) { + Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + Remove-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath + + if (-not $SkipListenerReady) { + Restart-SmartPipeRunner -Root $runner -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath -FixturePath $ListenerFixturePath -TimeoutSeconds $ListenerTimeoutSeconds + } + Write-Output "Removed SmartPipe-owned hook, environment entry, and label from $runner and restored one listener." +} +catch { + $errorText = [string]$_.Exception.Message + Write-Error -Message "$errorText Recovery: confirm the runner and repository are idle, then inspect or rerun eng\runner\uninstall-runner.ps1; unrelated runner labels are never removed." + exit 1 +} diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 new file mode 100644 index 0000000..651e882 --- /dev/null +++ b/eng/tests/runner-contract.Tests.ps1 @@ -0,0 +1,316 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' +$cleanupScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'post-job-cleanup.ps1')) +$installScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'install-runner.ps1')) +$uninstallScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'uninstall-runner.ps1')) +$monitorScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'monitor-pr.ps1')) + +function Assert-RunnerEqual { + param( + [Parameter(Mandatory = $true)] $Actual, + [Parameter(Mandatory = $true)] $Expected, + [Parameter(Mandatory = $true)] [string] $Message + ) + + if ($Actual -ne $Expected) { + throw "$Message (actual: '$Actual'; expected: '$Expected')" + } +} + +function Assert-RunnerTrue { + param( + [Parameter(Mandatory = $true)] [bool] $Condition, + [Parameter(Mandatory = $true)] [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Invoke-RunnerScript { + param( + [Parameter(Mandatory = $true)] [string] $ScriptPath, + [Parameter(Mandatory = $true)] [string[]] $Arguments + ) + + $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($output | Out-String).Trim() + } +} + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-contract-$([Guid]::NewGuid().ToString('N'))" +$runnerRoot = Join-Path $fixture 'SmartPipe-Runner' +$workspace = Join-Path $runnerRoot '_work\SmartPipe.Core\SmartPipe.Core' +$tempRoot = Join-Path $runnerRoot '_temp' +$toolRoot = Join-Path $runnerRoot '_tool' +$sibling = Join-Path $runnerRoot '_work\Other.Repo\Other.Repo' + +try { + New-Item -ItemType Directory -Path $workspace, $tempRoot, $toolRoot, $sibling -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $workspace '.git'), (Join-Path $tempRoot 'SmartPipe.Core'), (Join-Path $tempRoot 'CodeQL') -Force | Out-Null + @' +{"agentName":"SmartPipe-Runner"} +'@ | Set-Content -LiteralPath (Join-Path $runnerRoot '.runner') + @' +[remote "origin"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + 'workspace output' | Set-Content -LiteralPath (Join-Path $workspace 'output.txt') + 'tool must survive' | Set-Content -LiteralPath (Join-Path $toolRoot 'preserve.txt') + 'sibling must survive' | Set-Content -LiteralPath (Join-Path $sibling 'preserve.txt') + 'known temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core\cache.txt') + 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') + 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') + + $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Post-job cleanup must succeed for a valid checkout. $($cleanup.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $tempRoot 'unrelated.tmp')) -Message 'Unrelated temp files must be preserved.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core'))) -Message 'Known SmartPipe temp must be removed.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' + + $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' + $absent = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $absentWorkspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $absent.ExitCode -Expected 0 -Message 'Absent cleanup targets must be successful.' + + New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null + @' +[remote "origin"] + url = https://github.com/example/other.git +# https://github.com/MrFr3di/SmartPipe-Core.git +[remote "upstream"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + $wrongRepo = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($wrongRepo.ExitCode -ne 0) -Message 'A checkout with a commented or secondary canonical remote must fail closed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A rejected checkout must not be deleted.' + + $outside = Join-Path $fixture 'outside' + New-Item -ItemType Directory -Path $outside -Force | Out-Null + $outsideResult = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $outside, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($outsideResult.ExitCode -ne 0) -Message 'A workspace outside the runner root must fail closed.' + + Remove-Item -LiteralPath $workspace -Recurse -Force + New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null + @' +[remote "origin"] + url = https://github.com/MrFr3di/SmartPipe-Core.git +'@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') + + $reparseCreated = $false + try { + New-Item -ItemType SymbolicLink -Path (Join-Path $workspace 'reparse') -Target $sibling -Force -ErrorAction Stop | Out-Null + $reparseCreated = $true + } + catch { + Write-Output 'Runner contract: symbolic-link fixture unavailable; reparse refusal remains covered by workflow cleanup contracts.' + } + if ($reparseCreated) { + $reparse = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($reparse.ExitCode -ne 0) -Message 'A reparse point must fail closed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace) -Message 'A reparse rejection must preserve the checkout.' + } + + Remove-Item -LiteralPath $workspace -Recurse -Force + @' +@echo off +exit /b 0 +'@ | Set-Content -LiteralPath (Join-Path $runnerRoot 'run.cmd') + $listenerFixture = Join-Path $fixture 'listener.count' + '1' | Set-Content -LiteralPath $listenerFixture -NoNewline + $runnerGh = Join-Path $fixture 'runner-gh.ps1' +$queuedFlag = Join-Path $fixture 'queued.flag' +$inProgressFlag = Join-Path $fixture 'in-progress.flag' +$offlineFlag = Join-Path $fixture 'offline.flag' + $labelState = Join-Path $fixture 'runner-labels.json' + @('self-hosted', 'Windows', 'X64', 'existing-label') | ConvertTo-Json -Compress | Set-Content -LiteralPath $labelState + @' +param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) +$joined = $Arguments -join ' ' +$labels = @((Get-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE -Raw | ConvertFrom-Json)) +if ($joined -like '*actions/runners/42/labels/smartpipe-cleanup-v1*') { + $labels = @($labels | Where-Object { $_ -ne 'smartpipe-cleanup-v1' }) + $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE + $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } +} +elseif ($joined -like '*actions/runners/42/labels*') { + if ('smartpipe-cleanup-v1' -notin $labels) { $labels += 'smartpipe-cleanup-v1' } + Remove-Item -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG -Force -ErrorAction SilentlyContinue + $labels | ConvertTo-Json -Compress | Set-Content -LiteralPath $env:SMARTPIPE_LABEL_STATE + $response = @{ labels = @($labels | ForEach-Object { @{ name = $_ } }) } +} +elseif ($joined -like '*actions/runs?status=queued*') { + if (Test-Path -LiteralPath $env:SMARTPIPE_QUEUED_FLAG) { $response = @{ workflow_runs = @(@{ id = 1 }) } } else { $response = @{ workflow_runs = @() } } +} +elseif ($joined -like '*actions/runs?status=in_progress*') { + if (Test-Path -LiteralPath $env:SMARTPIPE_IN_PROGRESS_FLAG) { $response = @{ workflow_runs = @(@{ id = 2 }) } } else { $response = @{ workflow_runs = @() } } +} +elseif ($joined -like '*actions/runners?*') { + $labelObjects = @($labels | ForEach-Object { @{ name = $_ } }) + $runnerStatus = if (Test-Path -LiteralPath $env:SMARTPIPE_OFFLINE_FLAG) { 'offline' } else { 'online' } + $response = @{ runners = @(@{ id = 42; name = 'SmartPipe-Runner'; status = $runnerStatus; busy = $false; labels = $labelObjects }) } +} +elseif ($null -eq $response) { + throw "Unexpected fake gh request: $joined" +} + $response | ConvertTo-Json -Depth 5 -Compress +'@ | Set-Content -LiteralPath $runnerGh + $env:SMARTPIPE_QUEUED_FLAG = $queuedFlag + $env:SMARTPIPE_IN_PROGRESS_FLAG = $inProgressFlag + $env:SMARTPIPE_OFFLINE_FLAG = $offlineFlag + $env:SMARTPIPE_LABEL_STATE = $labelState + $environment = Join-Path $runnerRoot '.env' +@' +UNRELATED_ENV=preserve +'@ | Set-Content -LiteralPath $environment + + New-Item -ItemType File -Path $queuedFlag -Force | Out-Null + $queuedInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($queuedInstall.ExitCode -ne 0) -Message "Installer must refuse queued Actions runs before mutation. $($queuedInstall.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' + Remove-Item -LiteralPath $queuedFlag -Force + + New-Item -ItemType File -Path $inProgressFlag -Force | Out-Null + $inProgressInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($inProgressInstall.ExitCode -ne 0) -Message "Installer must refuse in-progress Actions runs before mutation. $($inProgressInstall.Output)" + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' + Remove-Item -LiteralPath $inProgressFlag -Force + + New-Item -ItemType File -Path $offlineFlag -Force | Out-Null + $install = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $install.ExitCode -Expected 0 -Message "Installer must accept an idle fixture root and restore one listener. $($install.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Successful installation must leave exactly one listener fixture.' + $labelsAfterInstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) + Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -in $labelsAfterInstall) -Message 'Installer must register the cleanup label through GitHub.' + Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterInstall) -Message 'Installer must preserve unrelated runner labels.' + $installAgain = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' + $environmentLines = @(Get-Content -LiteralPath $environment) + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' + Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' + + $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' + $uninstalledLines = @(Get-Content -LiteralPath $environment) + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) + Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -notin $labelsAfterUninstall) -Message 'Uninstaller must remove only the owned cleanup label.' + Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterUninstall) -Message 'Uninstaller must preserve unrelated runner labels.' + + $fakeGh = Join-Path $fixture 'fake-gh.ps1' + $fakeCount = Join-Path $fixture 'fake-gh.count' + @' +param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Arguments) +$joined = $Arguments -join ' ' +if ($joined -like '*run list*') { + @(@{ databaseId = 99 }) | ConvertTo-Json -Compress + exit 0 +} +if ($joined -like '*run view*') { + "build error $([string]::new('x', 1400))" + exit 0 +} +$count = if (Test-Path -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) { [int](Get-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT) } else { 0 } +Set-Content -LiteralPath $env:SMARTPIPE_FAKE_GH_COUNT -Value ($count + 1) +@{ state = 'OPEN'; mergeStateStatus = 'DIRTY'; headRefOid = '0123456789abcdef0123456789abcdef01234567'; statusCheckRollup = @(@{ name = 'build'; status = 'COMPLETED'; conclusion = 'FAILURE' }) } | ConvertTo-Json -Compress +'@ | Set-Content -LiteralPath $fakeGh + $env:SMARTPIPE_FAKE_GH_COUNT = $fakeCount + $monitor = Invoke-RunnerScript -ScriptPath $monitorScript -Arguments @( + '-PullRequest', '42', + '-Repository', 'MrFr3di/SmartPipe-Core', + '-GhPath', $fakeGh, + '-PollSeconds', '1', + '-MaxPolls', '2' + ) + Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue + Assert-RunnerEqual -Actual $monitor.ExitCode -Expected 0 -Message "PR monitor fixture must succeed. $($monitor.Output)" + Assert-RunnerEqual -Actual @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR #42 transition:' }).Count -Expected 1 -Message 'PR monitor must emit only state transitions.' + $diagnosticLines = @($monitor.Output -split '\r?\n' | Where-Object { $_ -match '^PR diagnostic: first causal slice:' }) + Assert-RunnerEqual -Actual $diagnosticLines.Count -Expected 1 -Message 'PR monitor must emit one first-causal slice per failed head.' + Assert-RunnerTrue -Condition ($diagnosticLines[0].Length -le 1070) -Message 'PR monitor causal output must remain bounded.' + + Write-Output 'Runner contract tests passed (cleanup containment, lifecycle idempotence, and transition-only monitoring).' +} +finally { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_FAKE_GH_COUNT -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_QUEUED_FLAG -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_IN_PROGRESS_FLAG -ErrorAction SilentlyContinue + Remove-Item Env:\SMARTPIPE_OFFLINE_FLAG -ErrorAction SilentlyContinue +} diff --git a/eng/tests/workflow-contract.Tests.ps1 b/eng/tests/workflow-contract.Tests.ps1 index c3920fd..dcf05ae 100644 --- a/eng/tests/workflow-contract.Tests.ps1 +++ b/eng/tests/workflow-contract.Tests.ps1 @@ -7,3 +7,9 @@ python $testScript if ($LASTEXITCODE -ne 0) { throw "Workflow contract tests failed with exit code $LASTEXITCODE." } + +$runnerTestScript = Join-Path $PSScriptRoot 'runner-contract.Tests.ps1' +pwsh -NoProfile -File $runnerTestScript +if ($LASTEXITCODE -ne 0) { + throw "Runner contract tests failed with exit code $LASTEXITCODE." +} diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 66f45b9..7e372db 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -31,8 +31,8 @@ ) } SHA_REF = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") -SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64"] -SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64"]' +SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64", "smartpipe-cleanup-v1"] +SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' SAME_REPOSITORY_PR_GUARD = ( "github.event_name != 'pull_request' || " "github.event.pull_request.head.repo.full_name == github.repository" @@ -46,19 +46,30 @@ "always() && github.event_name == 'pull_request' && " "github.event.pull_request.head.repo.full_name == github.repository" ) +DIAGNOSTIC_INPUTS_EMPTY_GUARD = ( + "(github.event_name != 'workflow_dispatch' || " + "(inputs.diagnostic-sha == '' && inputs.diagnostic-scenario == '' && " + "inputs.diagnostic-repeat == ''))" +) +CI_NORMAL_GUARD = f"({SAME_REPOSITORY_PR_GUARD}) && {DIAGNOSTIC_INPUTS_EMPTY_GUARD}" +DIAGNOSTIC_GUARD = ( + "github.event_name == 'workflow_dispatch' && " + "(inputs.diagnostic-sha != '' || inputs.diagnostic-scenario != '' || " + "inputs.diagnostic-repeat != '')" +) CI_VALIDATION_RUNNER_INPUT = ( "${{ github.event_name == 'pull_request' && " - "'[\"self-hosted\",\"Windows\",\"X64\"]' || " + "'[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]' || " "'[\"ubuntu-latest\"]' }}" ) CI_WINDOWS_RUNNER = ( "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'windows-latest' }}" ) CODEQL_RUNNER = ( "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || " + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'ubuntu-latest' }}" ) CODEQL_PR_RAM = ( @@ -78,7 +89,7 @@ HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" HOSTING_RUNNER = ( "${{ matrix.os == 'self-hosted' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\"]') || matrix.os }}" + "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || matrix.os }}" ) HOSTING_MATRIX = ( "${{ fromJSON(github.event_name == 'pull_request' && " @@ -134,12 +145,87 @@ def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: f"{workflow_name} must isolate pull-request NuGet packages inside GITHUB_WORKSPACE.") +def assert_diagnostic_contract(ci: dict) -> None: + dispatch = ci.get("on", {}).get("workflow_dispatch", {}) + inputs = dispatch.get("inputs", {}) if isinstance(dispatch, dict) else {} + require(set(inputs) == {"diagnostic-sha", "diagnostic-scenario", "diagnostic-repeat"}, + "CI diagnostic dispatch must expose exactly SHA, scenario, and repeat inputs.") + for name in inputs: + definition = inputs[name] + require(definition.get("required") is False + and definition.get("type") == "string" + and definition.get("default") == "", + f"CI diagnostic input {name} must be an optional empty string.") + + job = ci["jobs"].get("diagnostic-consumer") + require(isinstance(job, dict), "CI must define the optional diagnostic-consumer job.") + require(job.get("if") == DIAGNOSTIC_GUARD, + "Diagnostic consumer must run only for a workflow dispatch with diagnostic input.") + require_self_hosted_windows(job, "Diagnostic consumer") + diagnostic_steps = steps(job, "diagnostic-consumer") + validation = named_step(diagnostic_steps, "Validate diagnostic inputs") + validation_script = str(validation.get("run", "")) + for token in ("^[0-9a-f]{40}$", "^[a-z0-9-]+$", "^[1-5]$"): + require(token in validation_script, + f"Diagnostic input validation must enforce {token}.") + checkout = next( + step for step in diagnostic_steps + if str(step.get("uses", "")).startswith("actions/checkout") + ) + require(checkout.get("with", {}).get("ref") == "${{ inputs.diagnostic-sha }}" + and checkout.get("with", {}).get("persist-credentials") is False, + "Diagnostic consumer must checkout the exact requested SHA without credentials.") + verify = named_step(diagnostic_steps, "Verify exact diagnostic checkout") + require("git rev-parse HEAD" in str(verify.get("run", "")) + and "DIAGNOSTIC_SHA" in str(verify.get("run", "")), + "Diagnostic consumer must verify the checked out commit SHA.") + restore = named_step(diagnostic_steps, "Restore locked") + require(str(restore.get("run", "")).strip() == "dotnet restore SmartPipe.Core.slnx --locked-mode", + "Diagnostic consumer must perform one locked solution restore.") + build = named_step(diagnostic_steps, "Build") + require("--no-restore" in str(build.get("run", "")) + and "dotnet build SmartPipe.Core.slnx" in str(build.get("run", "")), + "Diagnostic consumer must build once after restore.") + pack = named_step(diagnostic_steps, "Pack packages from graph") + pack_run = str(pack.get("run", "")) + require("pack-packages" in pack_run + and "--output artifacts/packages" in pack_run + and "--manifest artifacts/packages/manifest.json" in pack_run, + "Diagnostic consumer must pack once from the package graph.") + run = named_step(diagnostic_steps, "Run diagnostic consumer") + run_script = str(run.get("run", "")) + require("--scenario $env:DIAGNOSTIC_SCENARIO" in run_script + and "DIAGNOSTIC_REPEAT" in run_script + and "for ($pass = 1;" in run_script + and "$pass -le [int]$env:DIAGNOSTIC_REPEAT" in run_script, + "Diagnostic consumer must invoke exactly the selected scenario one to five times.") + require("GITHUB_STEP_SUMMARY" in run_script + and "8192" in run_script + and "upload-artifact" not in "\n".join( + str(step) for step in diagnostic_steps + ), + "Diagnostic consumer must write only a bounded summary and no artifact upload.") + commands = [command for command in runs(diagnostic_steps) + if "dotnet restore SmartPipe.Core.slnx" in command + or "dotnet build SmartPipe.Core.slnx" in command + or "pack-packages" in command] + require(sum("dotnet restore SmartPipe.Core.slnx" in command for command in commands) == 1 + and sum("dotnet build SmartPipe.Core.slnx" in command for command in commands) == 1 + and sum("pack-packages" in command for command in commands) == 1, + "Diagnostic consumer must restore, build, and pack exactly once.") + + def require_same_repository_pr_guard(job: dict, label: str, allow_non_pr: bool = True) -> None: expected = SAME_REPOSITORY_PR_GUARD if allow_non_pr else PULL_REQUEST_SAME_REPOSITORY_GUARD require(job.get("if") == expected, f"{label} must use the same-repository pull_request guard.") +def require_ci_normal_job_guard(job: dict, label: str) -> None: + require(job.get("if") == CI_NORMAL_GUARD, + f"{label} must retain the same-repository guard and skip only diagnostic dispatches.") + + def assert_cleanup_job( workflow: dict, workflow_name: str, @@ -382,8 +468,6 @@ def assert_repository_checks_profile( "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", "Verify release versions current", "Run current consumers", - "Run Hosting consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Upload immutable packages and reports", @@ -581,10 +665,32 @@ def validate(documents: dict[str, dict]) -> None: branches = ci.get("on", {}).get(event, {}).get("branches", []) require("release/2.2.0" in branches, f"CI {event} must include release/2.2.0.") + assert_diagnostic_contract(ci) expected_triggers = { "ci.yml": { - "workflow_dispatch": None, + "workflow_dispatch": { + "inputs": { + "diagnostic-sha": { + "description": "Exact 40-character commit SHA for a single-consumer diagnostic", + "required": False, + "type": "string", + "default": "", + }, + "diagnostic-scenario": { + "description": "Exact consumer scenario ID for a single-consumer diagnostic", + "required": False, + "type": "string", + "default": "", + }, + "diagnostic-repeat": { + "description": "Number of diagnostic runs (1-5)", + "required": False, + "type": "string", + "default": "", + }, + }, + }, "push": {"branches": ["main", "upd", "release/2.2.0"]}, "pull_request": { "branches": ["main", "upd", "release/2.2.0", "sp220/checkpoint-c", "sp220/checkpoint-d"] @@ -675,8 +781,7 @@ def validate(documents: dict[str, dict]) -> None: "Pack packages from graph", "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", "Verify release versions current", - "Run current consumers", "Run Hosting consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", + "Run current consumers", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Docs link check", "Docs link check (Windows)", "Upload immutable packages and reports", @@ -685,17 +790,25 @@ def validate(documents: dict[str, dict]) -> None: named_step(reusable_steps, name) gate_order = [ "Restore locked", "Build", "Verify RepositoryChecks profile", - "Test and benchmark warning gate", "Pack packages from graph", + "Pack packages from graph", "Provision 2.1.2 baseline packages", "Verify package graph current", "Verify package metadata current", "Verify package ownership current", - "Verify release versions current", "Run current consumers", "Run HealthChecks consumers", - "Run OpenTelemetry consumers", "Vulnerable package scan", "Verify direct production audit policy", + "Verify release versions current", "Run current consumers", + "Test and benchmark warning gate", "Vulnerable package scan", "Verify direct production audit policy", "Deprecated package scan", "Outdated package report", "Upload immutable packages and reports", ] gate_indexes = [reusable_steps.index(named_step(reusable_steps, name)) for name in gate_order] require(gate_indexes == sorted(gate_indexes), "Reusable package gates must follow the required order.") + wide_tests_index = reusable_steps.index(named_step(reusable_steps, "Core correctness regressions")) + for name in ( + "Pack packages from graph", "Verify package graph current", + "Verify package metadata current", "Verify package ownership current", + "Verify release versions current", "Run current consumers", + ): + require(reusable_steps.index(named_step(reusable_steps, name)) < wide_tests_index, + f"{name} must run before wide tests.") reusable_text = "\n".join(reusable_runs) pack_run = str(named_step(reusable_steps, "Pack packages from graph").get("run", "")) for token in ("pack-packages", "--mode current", "--configuration Release", @@ -704,15 +817,16 @@ def validate(documents: dict[str, dict]) -> None: require(token in pack_run, f"Graph-driven pack step must contain '{token}'.") require(reusable_text.count("pack-packages") == 1, "Reusable validation must invoke pack-packages exactly once.") - hosting_consumers = str(named_step(reusable_steps, "Run Hosting consumers").get("run", "")) - require("run-consumers" in hosting_consumers and "--category hosting" in hosting_consumers, - "Reusable validation must execute the Hosting consumer category.") - health_checks_consumers = str(named_step(reusable_steps, "Run HealthChecks consumers").get("run", "")) - require("run-consumers" in health_checks_consumers and "--category health-checks" in health_checks_consumers, - "Reusable validation must execute the HealthChecks consumer category.") - opentelemetry_consumers = str(named_step(reusable_steps, "Run OpenTelemetry consumers").get("run", "")) - require("run-consumers" in opentelemetry_consumers and "--category opentelemetry" in opentelemetry_consumers, - "Reusable validation must execute the OpenTelemetry consumer category.") + current_consumers = [ + str(step.get("run", "")) + for step in reusable_steps + if "run-consumers" in str(step.get("run", "")) + and "--set current" in str(step.get("run", "")) + ] + require(len(current_consumers) == 1 + and "--category" not in current_consumers[0] + and "--scenario" not in current_consumers[0], + "Reusable validation must execute exactly one full current consumer run.") concurrency_job = reusable["jobs"].get("health-checks-concurrency") require(isinstance(concurrency_job, dict), "Reusable validation must define the HealthChecks concurrency OS matrix.") @@ -768,7 +882,7 @@ def validate(documents: dict[str, dict]) -> None: require(validation == { "uses": "./.github/workflows/reusable-release-validation.yml", "permissions": {"contents": "read"}, - "if": SAME_REPOSITORY_PR_GUARD, + "if": CI_NORMAL_GUARD, "with": {"runner-labels": CI_VALIDATION_RUNNER_INPUT}, }, "CI validation must be the exact reusable workflow caller with read-only contents permission.") pull_request = ci.get("on", {}).get("pull_request", {}) @@ -778,7 +892,7 @@ def validate(documents: dict[str, dict]) -> None: require(isinstance(hosting_integration, dict) and hosting_integration.get("name") == f"Hosting integration ({HOSTING_NAME})", "CI must preserve the Hosting integration check name across event routes.") - require_same_repository_pr_guard(hosting_integration, "Hosting integration") + require_ci_normal_job_guard(hosting_integration, "Hosting integration") require_runner_expression(hosting_integration, HOSTING_RUNNER, "Hosting integration") hosting_strategy = hosting_integration.get("strategy") require(isinstance(hosting_strategy, dict) @@ -795,7 +909,7 @@ def validate(documents: dict[str, dict]) -> None: windows = ci["jobs"].get("json-file-windows") require(isinstance(windows, dict), "CI must define the Windows JSON lane.") require_runner_expression(windows, CI_WINDOWS_RUNNER, "Windows JSON lane") - require_same_repository_pr_guard(windows, "Windows JSON lane") + require_ci_normal_job_guard(windows, "Windows JSON lane") windows_steps = steps(windows, "json-file-windows") windows_runs = runs(windows_steps) windows_restores = [command for command in windows_runs if "dotnet restore SmartPipe.Core.slnx" in command] @@ -816,7 +930,7 @@ def validate(documents: dict[str, dict]) -> None: and baseline_windows.get("name") == "Baseline contract (Windows)", "CI must define the uniquely named Windows baseline contract job.") require_runner_expression(baseline_windows, CI_WINDOWS_RUNNER, "Windows baseline contract lane") - require_same_repository_pr_guard(baseline_windows, "Windows baseline contract lane") + require_ci_normal_job_guard(baseline_windows, "Windows baseline contract lane") baseline_windows_steps = steps(baseline_windows, "Windows baseline contract lane") checkout = baseline_windows_steps[0] require(str(checkout.get("uses", "")).startswith("actions/checkout") @@ -1145,6 +1259,56 @@ def _remove_ci_runner_override(documents: dict[str, dict]) -> None: del documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] +def _remove_diagnostic_input(documents: dict[str, dict]) -> None: + del documents["ci.yml"]["on"]["workflow_dispatch"]["inputs"]["diagnostic-sha"] + + +def _make_diagnostic_hosted(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "windows-latest" + + +def _make_ci_normal_job_diagnostic_capable(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["json-file-windows"]["if"] = SAME_REPOSITORY_PR_GUARD + + +def _remove_diagnostic_sha_validation(documents: dict[str, dict]) -> None: + step = named_step( + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"], + "Validate diagnostic inputs", + ) + step["run"] = str(step["run"]).replace("^[0-9a-f]{40}", "^[0-9a-f]+") + + +def _remove_diagnostic_exact_checkout(documents: dict[str, dict]) -> None: + checkout = next( + step for step in documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"] + if str(step.get("uses", "")).startswith("actions/checkout") + ) + checkout["with"]["ref"] = "main" + + +def _remove_diagnostic_repeat_bound(documents: dict[str, dict]) -> None: + step = named_step( + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["steps"], + "Validate diagnostic inputs", + ) + step["run"] = str(step["run"]).replace("^[1-5]$", "^[0-9]+$") + + +def _duplicate_current_consumer_run(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + current = named_step(job["steps"], "Run current consumers") + job["steps"].append(copy.deepcopy(current)) + + +def _move_current_consumer_after_wide_tests(documents: dict[str, dict]) -> None: + job = documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"] + current = named_step(job["steps"], "Run current consumers") + job["steps"].remove(current) + wide = job["steps"].index(named_step(job["steps"], "Core correctness regressions")) + job["steps"].insert(wide + 1, current) + + def _change_runner_default(documents: dict[str, dict]) -> None: documents["reusable-release-validation.yml"]["on"]["workflow_call"]["inputs"]["runner-labels"][ "default" @@ -1312,6 +1476,46 @@ def assert_mutation_rejected(documents: dict[str, dict], mutate, expected: str) def main() -> int: documents = load_workflows() validate(documents) + assert_mutation_rejected( + documents, + _remove_diagnostic_input, + "exactly SHA, scenario, and repeat inputs", + ) + assert_mutation_rejected( + documents, + _make_diagnostic_hosted, + "must target the self-hosted Windows X64 runner labels", + ) + assert_mutation_rejected( + documents, + _make_ci_normal_job_diagnostic_capable, + "skip only diagnostic dispatches", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_sha_validation, + "^[0-9a-f]{40}$", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_exact_checkout, + "exact requested SHA", + ) + assert_mutation_rejected( + documents, + _remove_diagnostic_repeat_bound, + "^[1-5]$", + ) + assert_mutation_rejected( + documents, + _duplicate_current_consumer_run, + "Expected exactly one step named 'Run current consumers'", + ) + assert_mutation_rejected( + documents, + _move_current_consumer_after_wide_tests, + "must run before wide tests", + ) assert_mutation_rejected( documents, lambda docs: _remove_reusable_step(docs, "Verify RepositoryChecks profile"), @@ -1340,11 +1544,6 @@ def main() -> int: lambda docs: _remove_reusable_step(docs, "Pack packages from graph"), "Pack packages from graph", ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run HealthChecks consumers"), - "Run HealthChecks consumers", - ) assert_mutation_rejected( documents, lambda docs: _remove_reusable_step(docs, "Verify direct production audit policy"), @@ -1446,11 +1645,6 @@ def main() -> int: lambda docs: _remove_reusable_step(docs, "Hosting tests"), "Hosting tests", ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run Hosting consumers"), - "Run Hosting consumers", - ) assert_mutation_rejected( documents, _use_hosted_runner_for_required_lanes, @@ -1614,16 +1808,6 @@ def main() -> int: _move_graph_before_integrity, "required order", ) - assert_mutation_rejected( - documents, - _move_opentelemetry_consumers_before_pack, - "required order", - ) - assert_mutation_rejected( - documents, - lambda docs: _remove_reusable_step(docs, "Run OpenTelemetry consumers"), - "Run OpenTelemetry consumers", - ) assert_mutation_rejected( documents, _duplicate_upload, diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs index 1c4f3c5..3fcaef6 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/CommandLineParserTests.cs @@ -319,6 +319,82 @@ public void Parse_RunConsumersAcceptsHostingCategory() ])); Assert.Equal("hosting", command.Category); + Assert.Null(command.Scenario); + } + + [Fact] + public void Parse_RunConsumersAcceptsExactScenario() + { + using var repository = new CommandRepository(); + + var command = Assert.IsType(CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", "dependency-injection-nativeaot", + ])); + + Assert.Equal("dependency-injection-nativeaot", command.Scenario); + Assert.Null(command.Category); + } + + [Fact] + public void Parse_RunConsumersRejectsCategoryAndScenarioTogether() + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--category", "hosting", + "--scenario", "hosting-direct", + ])); + + Assert.Equal("Options '--category' and '--scenario' are mutually exclusive.", error.Message); + } + + [Theory] + [InlineData("Dependency-Injection")] + [InlineData("dependency_injection")] + [InlineData("dependency.injection")] + [InlineData("")] + public void Parse_RunConsumersRejectsMalformedScenario(string scenario) + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", scenario, + ])); + + Assert.Equal("Option '--scenario' must contain lowercase letters, digits, or hyphens.", error.Message); + } + + [Fact] + public void Parse_RunConsumersRejectsDuplicateScenarioOption() + { + using var repository = new CommandRepository(); + + var error = Assert.Throws(() => CommandLineParser.Parse( + [ + "run-consumers", "--repo-root", repository.Path, + "--set", "current", + "--package-directory", "packages", + "--package-version", "2.2.0", + "--scenario", "core-direct", + "--scenario", "json-direct", + ])); + + Assert.Equal("Duplicate option '--scenario'.", error.Message); } private static string[] CaptureArgs(string repositoryRoot) diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs index 88080d7..511a888 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Consumers/ConsumerScenarioRunnerTests.cs @@ -12,6 +12,60 @@ namespace SmartPipe.RepositoryChecks.Tests.Consumers; [Collection(ExternalProcessCollection.Name)] public sealed class ConsumerScenarioRunnerTests { + [Fact] + public void NativeAotLibraryPreflight_IsNoOpOutsideWindows() + { + using var fixture = new RepositoryTestDirectory(); + var path = WriteLibraryAtEffectiveLength(fixture.Path, 260); + + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: false); + + Assert.True(File.Exists(path)); + } + + [Fact] + public void NativeAotLibraryPreflight_AllowsEffectiveLengthBelowWindowsLimit() + { + using var fixture = new RepositoryTestDirectory(); + WriteLibraryAtEffectiveLength(fixture.Path, 258); + + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: true); + } + + [Fact] + public void NativeAotLibraryPreflight_RejectsEffectiveLengthAtWindowsLimitWithoutAbsolutePath() + { + using var fixture = new RepositoryTestDirectory(); + var path = WriteLibraryAtEffectiveLength(fixture.Path, 260); + + var error = Assert.Throws(() => + ConsumerScenarioRunner.ValidateNativeAotLibraryPaths(fixture.Path, isWindows: true)); + + Assert.Equal("SPCONS025", error.Code); + Assert.Contains("260", error.Message, StringComparison.Ordinal); + Assert.Contains(Path.GetRelativePath(fixture.Path, path).Replace('\\', '/'), error.Message, StringComparison.Ordinal); + Assert.DoesNotContain(fixture.Path, error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RunConsumers_UnknownScenarioUsesExistingSelectionDiagnostic() + { + var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); + var options = new RunConsumersOptions( + root, + "current", + Path.Combine(root, "artifacts", "packages"), + "2.2.0", + "eng/consumer-scenarios.json", + Scenario: "does-not-exist"); + + var error = await Assert.ThrowsAsync(() => + new ConsumerScenarioRunner().RunAsync(options, TestContext.Current.CancellationToken)); + + Assert.Equal("SPCONS010", error.Code); + Assert.Contains("does-not-exist", error.Message, StringComparison.Ordinal); + } + [Fact] public void ProcessFailure_IsBoundedSingleLineAndPointsToRelativeRetainedEvidence() { @@ -494,6 +548,18 @@ private static string FixtureExecutable() "SmartPipe.RepositoryChecks.ProcessFixture" + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); } + private static string WriteLibraryAtEffectiveLength(string root, int effectiveLength) + { + var relativeLength = effectiveLength - Path.GetFullPath(root).Length - 2; + var directoryLength = relativeLength - "native.lib".Length - 1; + Assert.InRange(directoryLength, 1, 240); + var path = Path.Combine(root, new string('d', directoryLength), "native.lib"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, []); + Assert.Equal(effectiveLength, Path.GetFullPath(path).Length + 1); + return path; + } + private static ExpectedPublishDiagnostic DiagnosticExpectation() => new() { Code = "IL2026", From 6ef844c0d741aceffa19af108e0517b6c7f69e1e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Sun, 23 Aug 2026 01:39:19 +0500 Subject: [PATCH 2/8] fix(ci): harden self-hosted runner lifecycle --- docs/contributing.md | 5 ++++- eng/runner/install-runner.ps1 | 3 ++- eng/runner/post-job-cleanup.ps1 | 2 ++ eng/runner/runner-safety.ps1 | 8 ++++++-- eng/tests/runner-contract.Tests.ps1 | 28 +++++++++++++++++++++++----- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 7d71acc..b1fe4bd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -107,7 +107,10 @@ other labels, stops listeners tied to the exact root, launches one hidden only that custom label and the owned entry/copies, preserves unrelated labels and `.env` lines, then performs the same bounded one-listener restart. A failed operation reports recovery guidance; never convert the runner to a service as -part of this operation. +part of this operation. The second owned `.env` entry points +`DOTNET_INSTALL_DIR` at `_work\_tool\dotnet`, giving `actions/setup-dotnet` a +writable persistent directory without granting access to +`C:\Program Files\dotnet`. The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index bc07c67..5e8ff82 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -72,7 +72,8 @@ try { Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force $environmentPath = Join-Path $runner '.env' - Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination + $dotnetInstallDirectory = Join-Path $runner '_work\_tool\dotnet' + Write-SmartPipeEnvironment -EnvironmentPath $environmentPath -HookPath $hookDestination -DotNetInstallDirectory $dotnetInstallDirectory Add-SmartPipeRunnerLabel -Repository $Repository -Runner $remoteRunner -GhPath $GhPath if (-not $SkipListenerReady) { diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/post-job-cleanup.ps1 index aad347a..8f34f82 100644 --- a/eng/runner/post-job-cleanup.ps1 +++ b/eng/runner/post-job-cleanup.ps1 @@ -21,6 +21,8 @@ try { } Assert-SmartPipeNoReparsePath -Path $runner -Boundary $runner + Set-Location -LiteralPath $runner + [Environment]::CurrentDirectory = $runner if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) { throw 'GITHUB_WORKSPACE is required.' diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index 6e7685e..d82b0ff 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -683,12 +683,16 @@ function Write-SmartPipeEnvironment { [string] $EnvironmentPath, [Parameter(Mandatory = $true)] - [string] $HookPath + [string] $HookPath, + + [Parameter(Mandatory = $true)] + [string] $DotNetInstallDirectory ) $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath $owned = @{ 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory } foreach ($key in $owned.Keys) { @@ -717,7 +721,7 @@ function Remove-SmartPipeEnvironment { } $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED') + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') for ($index = $lines.Count - 1; $index -ge 0; $index--) { foreach ($key in $ownedKeys) { if ($lines[$index] -match "^\s*${key}=") { diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 651e882..97f06fa 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -34,12 +34,29 @@ function Assert-RunnerTrue { function Invoke-RunnerScript { param( [Parameter(Mandatory = $true)] [string] $ScriptPath, - [Parameter(Mandatory = $true)] [string[]] $Arguments + [Parameter(Mandatory = $true)] [string[]] $Arguments, + [string] $WorkingDirectory = '' ) - $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { + $output = & pwsh -NoProfile -File $ScriptPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } + else { + $captureId = [Guid]::NewGuid().ToString('N') + $stdoutPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.out" + $stderrPath = Join-Path ([IO.Path]::GetTempPath()) "smartpipe-runner-$captureId.err" + try { + $process = Start-Process -FilePath pwsh -ArgumentList (@('-NoProfile', '-File', $ScriptPath) + $Arguments) -WorkingDirectory $WorkingDirectory -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + $output = @((Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue), (Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue)) + $exitCode = $process.ExitCode + } + finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } + } [pscustomobject]@{ - ExitCode = $LASTEXITCODE + ExitCode = $exitCode Output = ($output | Out-String).Trim() } } @@ -68,7 +85,7 @@ try { 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') - $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -WorkingDirectory $workspace -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -252,6 +269,7 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' $environmentLines = @(Get-Content -LiteralPath $environment) Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^DOTNET_INSTALL_DIR=' }).Count -Expected 1 -Message '.NET install directory entry must be unique.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' @@ -266,7 +284,7 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' $uninstalledLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) From a2f360b7e7703113c1e7f9b286bf41e58be94d5f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 00:18:14 +0500 Subject: [PATCH 3/8] fix(ci): replace unavailable hosted security gates --- .github/workflows/dependency-review.yml | 79 ++++++------- README.md | 2 +- docs/plans/2.2.0-extension-architecture.md | 2 +- .../2.2.0/SP220-00-governance-and-baseline.md | 2 +- eng/tests/workflow_contract_tests.py | 110 +++++++++++++++--- 5 files changed, 138 insertions(+), 57 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c9483ac..0d77c8e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,4 +1,4 @@ -name: Dependency Review +name: Repository security audit on: pull_request: @@ -6,52 +6,53 @@ on: permissions: contents: read - pull-requests: read jobs: - dependency-review: + repository-security-audit: + name: Repository security audit if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] + runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Dependency review - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json - cleanup-self-hosted: - name: Cleanup self-hosted workspace - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [dependency-review] - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs + - name: Restore locked + shell: pwsh + run: | + dotnet restore SmartPipe.Core.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Build repository checks + shell: pwsh + run: | + dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify repository package contracts + shell: pwsh + run: | + dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify --profile sp220-05 --format github --failures-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Vulnerable package scan + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/audit -Force | Out-Null + dotnet package list --project SmartPipe.Core.slnx --vulnerable --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/vulnerable.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify direct production audit policy + shell: pwsh + run: dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj --configuration Release --no-build --no-restore -- verify-nuget-audit --repo-root . --report artifacts/audit/vulnerable.json + + - name: Deprecated package scan shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } - $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } - $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" - $targets = [Collections.Generic.List[string]]::new() - $targets.Add((Join-Path $workspace 'artifacts')) - $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($workspace) - while ($pending.Count) { - foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { - if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } - else { $pending.Push($directory.FullName) } - } - } - foreach ($target in $targets | Sort-Object Length -Descending -Unique) { - $fullPath = [IO.Path]::GetFullPath($target) - if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } - if (Test-Path -LiteralPath $fullPath -PathType Container) { - if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } - if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } - Remove-Item -LiteralPath $fullPath -Recurse -Force - } - } + dotnet package list --project SmartPipe.Core.slnx --deprecated --include-transitive --format json --output-version 1 --no-restore > artifacts/audit/deprecated.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/README.md b/README.md index 5984acb..871f969 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ stage handling, observer events, metrics snapshots, and dead-letter records with replay context. It is not a distributed workflow engine, message broker, durable queue, or exactly-once delivery system. -[![CI](https://github.com/MrFr3di/SmartPipe-Core/actions/workflows/ci.yml/badge.svg)](https://github.com/MrFr3di/SmartPipe-Core/actions) +[CI workflow](.github/workflows/ci.yml) [![NuGet Core](https://img.shields.io/nuget/v/SmartPipe.Core.svg)](https://www.nuget.org/packages/SmartPipe.Core) [![NuGet Extensions](https://img.shields.io/nuget/v/SmartPipe.Extensions.svg)](https://www.nuget.org/packages/SmartPipe.Extensions) [![NuGet JSON Extensions](https://img.shields.io/nuget/v/SmartPipe.Extensions.Json.svg)](https://www.nuget.org/packages/SmartPipe.Extensions.Json) diff --git a/docs/plans/2.2.0-extension-architecture.md b/docs/plans/2.2.0-extension-architecture.md index 35d692a..5e13292 100644 --- a/docs/plans/2.2.0-extension-architecture.md +++ b/docs/plans/2.2.0-extension-architecture.md @@ -1838,7 +1838,7 @@ Checkpoint G: SP220-16 + 17 18. [Polly — DI pipeline registry](https://github.com/App-vNext/Polly/blob/main/src/Polly.Extensions/DependencyInjection/PollyServiceCollectionExtensions.cs) 19. [Serilog.Extensions.Hosting](https://github.com/serilog/serilog-extensions-hosting) 20. [MassTransit EntityFrameworkCore integration](https://github.com/MassTransit/MassTransit/tree/develop/src/Persistence/MassTransit.EntityFrameworkCoreIntegration) -21. [SmartPipe.Core baseline](https://github.com/MrFr3di/SmartPipe-Core/tree/8e79902d22de714f493582946f7c260462b0895e) +21. SmartPipe.Core baseline commit `8e79902d22de714f493582946f7c260462b0895e`; [tracked baseline manifest](../../eng/baselines/2.1.2/manifest.json) # 36. Финальная директива diff --git a/docs/plans/2.2.0/SP220-00-governance-and-baseline.md b/docs/plans/2.2.0/SP220-00-governance-and-baseline.md index 8130286..2df74ee 100644 --- a/docs/plans/2.2.0/SP220-00-governance-and-baseline.md +++ b/docs/plans/2.2.0/SP220-00-governance-and-baseline.md @@ -2248,7 +2248,7 @@ SP220-01 may extend `SmartPipe.RepositoryChecks` with package graph allowlists a 5. GitHub Docs — protected branches: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches 6. GitHub Docs — repository rulesets: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets 7. GitHub Docs — security hardening for Actions: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions -8. SmartPipe repository baseline commit: https://github.com/MrFr3di/SmartPipe-Core/commit/8e79902d22de714f493582946f7c260462b0895e +8. SmartPipe repository baseline commit: `8e79902d22de714f493582946f7c260462b0895e`; tracked baseline manifest: [eng/baselines/2.1.2/manifest.json](../../../eng/baselines/2.1.2/manifest.json) --- diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 7e372db..0105b1d 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -278,6 +278,76 @@ def assert_cleanup_job( f"{workflow_name} cleanup must check direct target reparse points before recursion.") +def assert_repository_security_audit_contract(workflow: dict) -> None: + require(workflow.get("name") == "Repository security audit", + "Dependency Review workflow must identify the repository-controlled security audit.") + require(workflow.get("permissions") == {"contents": "read"}, + "Repository security audit must request only read access to repository contents.") + jobs = workflow.get("jobs", {}) + require("cleanup-self-hosted" not in jobs, + "Hosted repository security audit must not depend on self-hosted cleanup.") + job = jobs.get("repository-security-audit") + require(isinstance(job, dict), + "Dependency Review workflow must define repository-security-audit.") + require(job.get("name") == "Repository security audit", + "Repository security audit must preserve its distinct check name.") + require(job.get("if") == PULL_REQUEST_SAME_REPOSITORY_GUARD, + "Repository security audit must run only for same-repository pull requests.") + require(job.get("runs-on") == "ubuntu-latest", + "Repository security audit must use hosted Linux.") + require("self-hosted" not in str(job.get("runs-on", "")), + "Repository security audit must not use a self-hosted runner.") + + job_steps = steps(job, "Repository security audit") + checkouts = [step for step in job_steps + if str(step.get("uses", "")).startswith("actions/checkout")] + require(len(checkouts) == 1 + and checkouts[0].get("with", {}).get("persist-credentials") is False, + "Repository security audit checkout must be pinned and credential-free.") + setup = [step for step in job_steps + if str(step.get("uses", "")).startswith("actions/setup-dotnet")] + require(len(setup) == 1 + and setup[0].get("with", {}).get("global-json-file") == "global.json", + "Repository security audit setup-dotnet must use global.json as the SDK source.") + require(not any("actions/dependency-review-action" in str(step.get("uses", "")) + for step in job_steps), + "Repository security audit must not claim hosted Dependency Review execution.") + require(not any(step.get("continue-on-error") for step in job_steps), + "Repository security audit must fail closed without continue-on-error.") + + restore = named_step(job_steps, "Restore locked") + require("dotnet restore SmartPipe.Core.slnx --locked-mode" in str(restore.get("run", "")), + "Repository security audit must perform locked restore.") + build = named_step(job_steps, "Build repository checks") + require(build.get("shell") == "pwsh" + and "dotnet build eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " + "--configuration Release --no-restore -warnaserror" in str(build.get("run", "")), + "Repository security audit must build RepositoryChecks with warnings as errors.") + profile = named_step(job_steps, "Verify repository package contracts") + require(profile.get("shell") == "pwsh" + and "dotnet run --project eng/SmartPipe.RepositoryChecks/SmartPipe.RepositoryChecks.csproj " + "--configuration Release --no-build --no-restore -- verify --profile sp220-05 " + "--format github --failures-only" in str(profile.get("run", "")), + "Repository security audit must run the strict repository package profile.") + vulnerable = named_step(job_steps, "Vulnerable package scan") + require(vulnerable.get("shell") == "pwsh" + and "dotnet package list --project SmartPipe.Core.slnx --vulnerable " + "--include-transitive --format json --output-version 1 --no-restore" in str(vulnerable.get("run", "")) + and "artifacts/audit/vulnerable.json" in str(vulnerable.get("run", "")), + "Repository security audit must produce a strict vulnerable package report.") + audit = named_step(job_steps, "Verify direct production audit policy") + require(audit.get("shell") == "pwsh" + and "verify-nuget-audit" in str(audit.get("run", "")) + and "--report artifacts/audit/vulnerable.json" in str(audit.get("run", "")), + "Repository security audit must enforce the repository NuGet audit policy.") + deprecated = named_step(job_steps, "Deprecated package scan") + require(deprecated.get("shell") == "pwsh" + and "dotnet package list --project SmartPipe.Core.slnx --deprecated " + "--include-transitive --format json --output-version 1 --no-restore" in str(deprecated.get("run", "")) + and "artifacts/audit/deprecated.json" in str(deprecated.get("run", "")), + "Repository security audit must report deprecated packages without suppressing failures.") + + def assert_reusable_windows_shell_contract(reusable_steps: list[dict]) -> None: release_version = named_step(reusable_steps, "Test release version validation") release_run = str(release_version.get("run", "")) @@ -594,6 +664,18 @@ def assert_link_check_exclusion_scoped() -> None: "lychee.toml must not contain a broad nuget.org exclusion.") +def assert_private_repository_docs_links_are_local() -> None: + private_repository_prefix = "https://github.com/MrFr3di/SmartPipe-Core/" + sources = ( + ROOT / "README.md", + ROOT / "docs" / "plans" / "2.2.0-extension-architecture.md", + ROOT / "docs" / "plans" / "2.2.0" / "SP220-00-governance-and-baseline.md", + ) + for source in sources: + require(private_repository_prefix not in source.read_text(encoding="utf-8"), + f"{source.relative_to(ROOT)} must use local links for private repository references.") + + def assert_consumer_contract() -> None: manifest_path = ROOT / "eng" / "consumer-scenarios.json" document = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -976,12 +1058,7 @@ def validate(documents: dict[str, dict]) -> None: CLEANUP_PULL_REQUEST_GUARD, cleanup_nuget=True, ) - assert_cleanup_job( - dependency_review, - "dependency-review.yml", - ["dependency-review"], - CLEANUP_PULL_REQUEST_GUARD, - ) + assert_repository_security_audit_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") assert_nuget_isolation_contract(codeql, "codeql.yml") @@ -990,12 +1067,6 @@ def validate(documents: dict[str, dict]) -> None: require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") require_same_repository_pr_guard(codeql_job, "CodeQL analyze") assert_codeql_resource_contract(codeql_job) - dependency_review_job = dependency_review["jobs"].get("dependency-review") - require(isinstance(dependency_review_job, dict), - "Dependency Review must define the dependency-review job.") - require_self_hosted_windows(dependency_review_job, "Dependency Review") - require_same_repository_pr_guard(dependency_review_job, "Dependency Review", allow_non_pr=False) - all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -1008,6 +1079,7 @@ def validate(documents: dict[str, dict]) -> None: assert_persist_credentials_disabled(documents) assert_setup_dotnet_uses_global_json(documents) assert_link_check_exclusion_scoped() + assert_private_repository_docs_links_are_local() assert_consumer_contract() version = publish["jobs"].get("version") @@ -1188,12 +1260,15 @@ def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: ("reusable-release-validation.yml", "build-test-pack"), ("reusable-release-validation.yml", "health-checks-concurrency"), ("codeql.yml", "analyze"), - ("dependency-review.yml", "dependency-review"), ) for workflow_name, job_name in lanes: documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" +def _make_repository_security_audit_self_hosted(documents: dict[str, dict]) -> None: + documents["dependency-review.yml"]["jobs"]["repository-security-audit"]["runs-on"] = SELF_HOSTED_WINDOWS + + def _make_ci_validation_always_self_hosted(documents: dict[str, dict]) -> None: documents["ci.yml"]["jobs"]["validation"]["with"]["runner-labels"] = SELF_HOSTED_WINDOWS_JSON @@ -1650,6 +1725,11 @@ def main() -> int: _use_hosted_runner_for_required_lanes, "runner-labels workflow input", ) + assert_mutation_rejected( + documents, + _make_repository_security_audit_self_hosted, + "must use hosted Linux", + ) assert_mutation_rejected( documents, _make_ci_validation_always_self_hosted, @@ -1765,7 +1845,7 @@ def main() -> int: _remove_ci_cleanup_job, "must define cleanup-self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + for workflow_name in ("ci.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), @@ -1776,7 +1856,7 @@ def main() -> int: _make_ci_cleanup_delete_workspace_root, "must not delete the workspace root", ) - for workflow_name in ("ci.yml", "codeql.yml", "dependency-review.yml"): + for workflow_name in ("ci.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), From 0eca5db4833ba5f95ff58b5450a03210a97ecf04 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 01:28:31 +0500 Subject: [PATCH 4/8] fix(ci): move cleanup before self-hosted jobs --- .github/workflows/codeql.yml | 65 ++------- docs/contributing.md | 16 +- .../2.2.0-branch-and-review-policy.md | 2 +- .../Commands/BaselineCaptureService.cs | 7 +- .../Commands/BaselineVerificationService.cs | 28 +++- eng/baselines/README.md | 5 +- eng/runner/install-runner.ps1 | 12 +- ...-job-cleanup.ps1 => job-start-cleanup.ps1} | 2 +- eng/runner/runner-safety.ps1 | 8 +- eng/runner/uninstall-runner.ps1 | 2 +- eng/tests/runner-contract.Tests.ps1 | 32 ++-- eng/tests/workflow_contract_tests.py | 137 +++++++----------- .../Commands/BaselineOrchestrationTests.cs | 91 +++++++++++- 13 files changed, 227 insertions(+), 180 deletions(-) rename eng/runner/{post-job-cleanup.ps1 => job-start-cleanup.ps1} (97%) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 82ade7a..5327eed 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: CodeQL +name: Hosted .NET static analysis on: push: @@ -10,15 +10,11 @@ on: permissions: contents: read - security-events: write - -env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} jobs: analyze: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ${{ github.event_name == 'pull_request' && fromJSON('["self-hosted","Windows","X64","smartpipe-cleanup-v1"]') || 'ubuntu-latest' }} + name: Hosted .NET static analysis + runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -29,53 +25,14 @@ jobs: with: global-json-file: global.json - - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - languages: csharp - - - name: Build - run: dotnet build SmartPipe.Core.slnx -c Release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - ram: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '16384' || '' }} - threads: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && '2' || '' }} + - name: Restore locked + shell: pwsh + run: | + dotnet restore SmartPipe.Core.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cleanup-self-hosted: - name: Cleanup self-hosted workspace - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [analyze] - runs-on: [self-hosted, Windows, X64, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs + - name: Build static analysis shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { throw 'GITHUB_WORKSPACE is required.' } - $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - if ((Get-Item -LiteralPath $workspace -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Workspace is a reparse point.' } - $prefix = "$workspace$([IO.Path]::DirectorySeparatorChar)" - $targets = [Collections.Generic.List[string]]::new() - $targets.Add((Join-Path $workspace 'artifacts')) - $targets.Add((Join-Path $workspace 'BenchmarkDotNet.Artifacts')) - $targets.Add((Join-Path $workspace '.nuget')) - $pending = [Collections.Generic.Stack[string]]::new() - $pending.Push($workspace) - while ($pending.Count) { - foreach ($directory in Get-ChildItem -LiteralPath $pending.Pop() -Force -Directory) { - if ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } - if ($directory.Name -in 'bin', 'obj') { $targets.Add($directory.FullName) } - else { $pending.Push($directory.FullName) } - } - } - foreach ($target in $targets | Sort-Object Length -Descending -Unique) { - $fullPath = [IO.Path]::GetFullPath($target) - if (!$fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Outside workspace: $fullPath" } - if (Test-Path -LiteralPath $fullPath -PathType Container) { - if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point: $fullPath" } - if (Get-ChildItem -LiteralPath $fullPath -Force -Recurse | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { throw "Reparse point: $fullPath" } - Remove-Item -LiteralPath $fullPath -Recurse -Force - } - } + dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/docs/contributing.md b/docs/contributing.md index b1fe4bd..bb68ac0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -111,14 +111,18 @@ part of this operation. The second owned `.env` entry points `DOTNET_INSTALL_DIR` at `_work\_tool\dotnet`, giving `actions/setup-dotnet` a writable persistent directory without granting access to `C:\Program Files\dotnet`. +The hook entry is `ACTIONS_RUNNER_HOOK_JOB_STARTED`; upgrades remove the legacy +`ACTIONS_RUNNER_HOOK_JOB_COMPLETED` entry and hook copy before writing the new +owned state. -The post-job hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout +The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It -removes the exact checkout and the known `SmartPipe.Core`, `SmartPipe-Core`, -`CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing targets are -successful. Any outside path, broad root, reparse point, unsafe repository, or -deletion error fails closed before removal; the existing workflow cleanup jobs -remain as defense in depth. +runs before the next job starts, after the runner has completed the previous +job's process cleanup, and removes the exact prior checkout plus the known +`SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below +`RUNNER_TEMP`. Missing targets are successful. Any outside path, broad root, +reparse point, unsafe repository, or deletion error fails closed before +removal; the existing workflow cleanup jobs remain as defense in depth. For a compact, transition-only pull-request view: diff --git a/docs/governance/2.2.0-branch-and-review-policy.md b/docs/governance/2.2.0-branch-and-review-policy.md index d81303c..4ff6d50 100644 --- a/docs/governance/2.2.0-branch-and-review-policy.md +++ b/docs/governance/2.2.0-branch-and-review-policy.md @@ -42,7 +42,7 @@ The repository owner or administrator applies and verifies an active GitHub rule | Conversation resolution | Required | | Status checks | Required | | Branch currentness | Required, or enforced by merge queue | -| Checks | `CI / validation`, Windows JSON lane, CodeQL, Dependency Review, baseline contract | +| Checks | `CI / validation`, Windows JSON lane, Hosted .NET static analysis, Repository security audit, baseline contract | | Linear history | Disabled while merge commits are required for reviewed hotfix synchronization | | Bypass | Repository owner only; audited as described below | diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs index 050e924..d3b38f1 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs @@ -325,7 +325,12 @@ private static async Task> ReadWorkflowEvidenceA } var workflows = new List(3); - foreach (var requiredName in new[] { "CI", "CodeQL", "Dependency Review" }) + foreach (var requiredName in new[] + { + "CI", + "Hosted .NET static analysis", + "Repository security audit", + }) { var successful = runs.Where(run => string.Equals(run.WorkflowName, requiredName, StringComparison.Ordinal) diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs index 7b6db9f..ec85418 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs @@ -45,11 +45,25 @@ internal sealed class BaselineVerificationService private const string TargetRelease = "2.2.0"; private const string SolutionPath = "SmartPipe.Core.slnx"; private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(2); - private static readonly (string Name, string Path, string[] Events)[] RequiredWorkflowFiles = + private static readonly string[] HistoricalManifestWorkflowNames = + [ + "CI", + "CodeQL", + "Dependency Review", + ]; + + private static readonly string[] CurrentManifestWorkflowNames = + [ + "CI", + "Hosted .NET static analysis", + "Repository security audit", + ]; + + private static readonly (string Name, string Path, string[] Events)[] CurrentWorkflowPolicy = [ ("CI", ".github/workflows/ci.yml", ["push", "pull_request"]), - ("CodeQL", ".github/workflows/codeql.yml", ["push", "pull_request"]), - ("Dependency Review", ".github/workflows/dependency-review.yml", ["pull_request"]), + ("Hosted .NET static analysis", ".github/workflows/codeql.yml", ["push", "pull_request"]), + ("Repository security audit", ".github/workflows/dependency-review.yml", ["pull_request"]), ]; private readonly IProcessRunner _processRunner; @@ -118,9 +132,11 @@ internal async Task VerifyAsync( var workflowNames = manifest.Repository.RequiredWorkflows .Select(static workflow => workflow.Name) .ToHashSet(StringComparer.Ordinal); - if (RequiredWorkflowFiles.Any(workflow => !workflowNames.Contains(workflow.Name))) + if (!workflowNames.SetEquals(HistoricalManifestWorkflowNames) + && !workflowNames.SetEquals(CurrentManifestWorkflowNames)) { - throw new JsonException("Manifest workflow evidence must include CI, CodeQL, and Dependency Review."); + throw new JsonException( + "Manifest workflow evidence must contain exactly either CI, CodeQL, and Dependency Review or CI, Hosted .NET static analysis, and Repository security audit."); } // Resolve and de-alias every referenced path before any package, process, or repository work. @@ -313,7 +329,7 @@ internal async Task VerifyAsync( } var releaseBranch = $"release/{manifest.TargetRelease}"; - foreach (var workflow in RequiredWorkflowFiles) + foreach (var workflow in CurrentWorkflowPolicy) { var path = RepositoryPaths.ResolveWithinRoot(options.RepositoryRoot, workflow.Path, "workflow"); if (!WorkflowPolicyContainsBranch(path, workflow.Events, releaseBranch)) diff --git a/eng/baselines/README.md b/eng/baselines/README.md index c0200e1..203ac38 100644 --- a/eng/baselines/README.md +++ b/eng/baselines/README.md @@ -26,8 +26,9 @@ The manifest rejects unknown properties and schema versions. `repository.capture - `SPB007`-`SPB010`: package hash, signature, identity/assets, or dependencies mismatch; - `SPB014`: public API snapshot mismatch; - `SPB015`: repository dependency snapshot mismatch; -- `SPB016`: required release branch missing from CI, CodeQL, or Dependency Review workflow policy. +- `SPB016`: required release branch missing from CI, Hosted .NET static analysis, or Repository security audit workflow policy. Offline verification never fetches packages. It requires the capture commit to exist and be an ancestor of current HEAD, failing closed for unrelated or missing/shallow history. It hashes package bytes before signature or archive inspection and ignores unreferenced files in the baseline directory. -Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, CodeQL, and Dependency Review. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. +Capture consumes the literal JSON array produced by `gh run list --json databaseId,workflowName,headSha,status,conclusion,url,event,createdAt`. Every returned run must target the requested capture commit; that `headSha` is retained in each workflow manifest entry, and exactly one completed successful run is required for each of CI, Hosted .NET static analysis, and Repository security audit. Workflow policy verification uses a bounded parser for the repository's current YAML shape and checks `release/2.2.0` in the actual `on.push.branches` and/or `on.pull_request.branches` lists; comments, environment values, and step text do not count. +Capture requires those current check names exactly and persists those literal names. Offline verification accepts only a complete historical manifest set (`CI`, `CodeQL`, `Dependency Review`) or a complete current set; mixed or extra workflow identities fail closed. diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index 5e8ff82..7dfbd4e 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -38,7 +38,7 @@ try { $environmentPath = Join-Path $runner '.env' Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { $path = Join-Path $hookDirectory $name if (Test-Path -LiteralPath $path) { Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner @@ -53,7 +53,7 @@ try { exit 0 } - $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'post-job-cleanup.ps1') + $hookSource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'job-start-cleanup.ps1') $safetySource = Get-SmartPipeFullPath -Path (Join-Path $PSScriptRoot 'runner-safety.ps1') if (-not (Test-Path -LiteralPath $hookSource -PathType Leaf) -or -not (Test-Path -LiteralPath $safetySource -PathType Leaf)) { @@ -66,7 +66,13 @@ try { } Assert-SmartPipeNoReparsePath -Path $hookDirectory -Boundary $runner - $hookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + $legacyHookDestination = Join-Path $hookDirectory 'smartpipe-post-job-cleanup.ps1' + if (Test-Path -LiteralPath $legacyHookDestination) { + Assert-SmartPipeNoReparsePath -Path $legacyHookDestination -Boundary $runner + Remove-Item -LiteralPath $legacyHookDestination -Force -ErrorAction Stop + } + + $hookDestination = Join-Path $hookDirectory 'smartpipe-job-start-cleanup.ps1' $safetyDestination = Join-Path $hookDirectory 'runner-safety.ps1' Copy-Item -LiteralPath $hookSource -Destination $hookDestination -Force Copy-Item -LiteralPath $safetySource -Destination $safetyDestination -Force diff --git a/eng/runner/post-job-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 similarity index 97% rename from eng/runner/post-job-cleanup.ps1 rename to eng/runner/job-start-cleanup.ps1 index 8f34f82..9cc69fa 100644 --- a/eng/runner/post-job-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -56,7 +56,7 @@ try { } } - Write-Output 'SmartPipe post-job cleanup completed.' + Write-Output 'SmartPipe job-start cleanup completed.' } catch { $errorText = [string]$_.Exception.Message diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index d82b0ff..241cda8 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -691,17 +691,19 @@ function Write-SmartPipeEnvironment { $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath $owned = @{ - 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = $HookPath + 'ACTIONS_RUNNER_HOOK_JOB_STARTED' = $HookPath 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory } - foreach ($key in $owned.Keys) { + foreach ($key in @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR')) { for ($index = $lines.Count - 1; $index -ge 0; $index--) { if ($lines[$index] -match "^\s*${key}=") { $lines.RemoveAt($index) } } + } + foreach ($key in $owned.Keys) { $lines.Add("$key=$($owned[$key])") } @@ -721,7 +723,7 @@ function Remove-SmartPipeEnvironment { } $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') + $ownedKeys = @('ACTIONS_RUNNER_HOOK_JOB_STARTED', 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', 'DOTNET_INSTALL_DIR') for ($index = $lines.Count - 1; $index -ge 0; $index--) { foreach ($key in $ownedKeys) { if ($lines[$index] -match "^\s*${key}=") { diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 index fc29aad..76e3484 100644 --- a/eng/runner/uninstall-runner.ps1 +++ b/eng/runner/uninstall-runner.ps1 @@ -33,7 +33,7 @@ try { Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath $hookDirectory = Join-Path $runner 'hooks' - foreach ($name in @('smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { + foreach ($name in @('smartpipe-job-start-cleanup.ps1', 'smartpipe-post-job-cleanup.ps1', 'runner-safety.ps1')) { $path = Join-Path $hookDirectory $name if (Test-Path -LiteralPath $path) { Assert-SmartPipeNoReparsePath -Path $path -Boundary $runner diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 97f06fa..cd01c01 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -3,7 +3,7 @@ param() $ErrorActionPreference = 'Stop' $runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' -$cleanupScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'post-job-cleanup.ps1')) +$jobStartScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'job-start-cleanup.ps1')) $installScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'install-runner.ps1')) $uninstallScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'uninstall-runner.ps1')) $monitorScript = [IO.Path]::GetFullPath((Join-Path $runnerScriptRoot 'monitor-pr.ps1')) @@ -85,14 +85,14 @@ try { 'known codeql temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'CodeQL\cache.txt') 'unrelated temp' | Set-Content -LiteralPath (Join-Path $tempRoot 'unrelated.tmp') - $cleanup = Invoke-RunnerScript -ScriptPath $cleanupScript -WorkingDirectory $workspace -Arguments @( + $cleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, '-Repository', 'MrFr3di/SmartPipe-Core', '-AllowTestRoot' ) - Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Post-job cleanup must succeed for a valid checkout. $($cleanup.Output)" + Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' @@ -101,7 +101,7 @@ try { Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' - $absent = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $absent = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $absentWorkspace, '-TempRoot', $tempRoot, @@ -118,7 +118,7 @@ try { [remote "upstream"] url = https://github.com/MrFr3di/SmartPipe-Core.git '@ | Set-Content -LiteralPath (Join-Path $workspace '.git\config') - $wrongRepo = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $wrongRepo = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -130,7 +130,7 @@ try { $outside = Join-Path $fixture 'outside' New-Item -ItemType Directory -Path $outside -Force | Out-Null - $outsideResult = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $outsideResult = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $outside, '-TempRoot', $tempRoot, @@ -155,7 +155,7 @@ try { Write-Output 'Runner contract: symbolic-link fixture unavailable; reparse refusal remains covered by workflow cleanup contracts.' } if ($reparseCreated) { - $reparse = Invoke-RunnerScript -ScriptPath $cleanupScript -Arguments @( + $reparse = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, '-WorkspaceRoot', $workspace, '-TempRoot', $tempRoot, @@ -217,7 +217,10 @@ elseif ($null -eq $response) { $environment = Join-Path $runnerRoot '.env' @' UNRELATED_ENV=preserve +ACTIONS_RUNNER_HOOK_JOB_COMPLETED=C:\legacy\smartpipe-post-job-cleanup.ps1 '@ | Set-Content -LiteralPath $environment + New-Item -ItemType Directory -Path (Join-Path $runnerRoot 'hooks') -Force | Out-Null + 'legacy hook' | Set-Content -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1') New-Item -ItemType File -Path $queuedFlag -Force | Out-Null $queuedInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( @@ -229,7 +232,7 @@ UNRELATED_ENV=preserve '-AllowTestRoot' ) Assert-RunnerTrue -Condition ($queuedInstall.ExitCode -ne 0) -Message "Installer must refuse queued Actions runs before mutation. $($queuedInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Queued-run refusal must not copy the hook.' Remove-Item -LiteralPath $queuedFlag -Force New-Item -ItemType File -Path $inProgressFlag -Force | Out-Null @@ -242,7 +245,7 @@ UNRELATED_ENV=preserve '-AllowTestRoot' ) Assert-RunnerTrue -Condition ($inProgressInstall.ExitCode -ne 0) -Message "Installer must refuse in-progress Actions runs before mutation. $($inProgressInstall.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'In-progress refusal must not copy the hook.' Remove-Item -LiteralPath $inProgressFlag -Force New-Item -ItemType File -Path $offlineFlag -Force | Out-Null @@ -268,11 +271,13 @@ UNRELATED_ENV=preserve ) Assert-RunnerEqual -Actual $installAgain.ExitCode -Expected 0 -Message 'Installer must be idempotent.' $environmentLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 1 -Message 'Hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_STARTED=' }).Count -Expected 1 -Message 'Job-start hook environment entry must be unique.' + Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^ACTIONS_RUNNER_HOOK_JOB_COMPLETED=' }).Count -Expected 0 -Message 'Legacy job-completed hook environment entry must be removed.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^DOTNET_INSTALL_DIR=' }).Count -Expected 1 -Message '.NET install directory entry must be unique.' Assert-RunnerEqual -Actual @($environmentLines | Where-Object { $_ -match '^SMARTPIPE_CLEANUP_LABEL=' }).Count -Expected 0 -Message 'Runner labels must not be represented by an environment marker.' Assert-RunnerTrue -Condition (@($environmentLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Installer must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1')) -Message 'Installer must copy the hook.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1')) -Message 'Installer must copy the job-start hook.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Installer must remove the legacy hook copy.' $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( '-RunnerRoot', $runnerRoot, @@ -284,9 +289,10 @@ UNRELATED_ENV=preserve Assert-RunnerEqual -Actual $uninstall.ExitCode -Expected 0 -Message "Uninstaller must succeed and restore one listener. $($uninstall.Output)" Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected '1' -Message 'Uninstall must leave exactly one listener fixture.' $uninstalledLines = @(Get-Content -LiteralPath $environment) - Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' + Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -match '^(ACTIONS_RUNNER_HOOK_JOB_STARTED|ACTIONS_RUNNER_HOOK_JOB_COMPLETED|DOTNET_INSTALL_DIR)=' }).Count -eq 0) -Message 'Uninstaller must remove only owned environment entries.' Assert-RunnerTrue -Condition (@($uninstalledLines | Where-Object { $_ -eq 'UNRELATED_ENV=preserve' }).Count -eq 1) -Message 'Uninstaller must preserve unrelated environment entries.' - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1'))) -Message 'Uninstaller must remove the owned hook copy.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Uninstaller must remove the legacy hook copy.' $labelsAfterUninstall = @((Get-Content -LiteralPath $labelState -Raw | ConvertFrom-Json)) Assert-RunnerTrue -Condition ('smartpipe-cleanup-v1' -notin $labelsAfterUninstall) -Message 'Uninstaller must remove only the owned cleanup label.' Assert-RunnerTrue -Condition ('existing-label' -in $labelsAfterUninstall) -Message 'Uninstaller must preserve unrelated runner labels.' diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 0105b1d..5b5926f 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -67,21 +67,6 @@ "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " "'windows-latest' }}" ) -CODEQL_RUNNER = ( - "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " - "'ubuntu-latest' }}" -) -CODEQL_PR_RAM = ( - "${{ github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository && " - "'16384' || '' }}" -) -CODEQL_PR_THREADS = ( - "${{ github.event_name == 'pull_request' && " - "github.event.pull_request.head.repo.full_name == github.repository && " - "'2' || '' }}" -) NUGET_PACKAGES_PR = ( "${{ github.event_name == 'pull_request' && " "format('{0}/.nuget/packages', github.workspace) || '' }}" @@ -129,13 +114,46 @@ def require_runner_expression(job: dict, expected: str, label: str) -> None: f"{label} must use the event-aware runner expression.") -def assert_codeql_resource_contract(job: dict) -> None: - analysis = named_step(steps(job, "CodeQL analyze"), "Perform CodeQL Analysis") - inputs = analysis.get("with") - require(isinstance(inputs, dict) - and inputs.get("ram") == CODEQL_PR_RAM - and inputs.get("threads") == CODEQL_PR_THREADS, - "CodeQL analyze resource cap must be limited to same-repository Windows pull requests.") +def assert_static_analysis_contract(workflow: dict) -> None: + require(workflow.get("name") == "Hosted .NET static analysis", + "Static-analysis workflow must identify the hosted .NET analyzer check honestly.") + require(workflow.get("permissions") == {"contents": "read"}, + "Static analysis must request only read access to repository contents.") + serialized = json.dumps(workflow).lower() + for forbidden in ("security-events", "codeql", "self-hosted", "cleanup-self-hosted"): + require(forbidden not in serialized, + f"Hosted static analysis must not retain {forbidden} configuration.") + + jobs = workflow.get("jobs", {}) + require(set(jobs) == {"analyze"}, + "Hosted static analysis must define only the analyzer job.") + job = jobs["analyze"] + require(job.get("name") == "Hosted .NET static analysis", + "Static analysis job must preserve its distinct check name.") + require(job.get("runs-on") == "ubuntu-latest", + "Static analysis must use hosted Linux.") + static_steps = steps(job, "Hosted .NET static analysis") + checkout = next( + step for step in static_steps + if str(step.get("uses", "")).startswith("actions/checkout") + ) + require(checkout.get("with", {}).get("persist-credentials") is False, + "Static analysis checkout must disable persisted credentials.") + setup = named_step(static_steps, "Setup .NET") + require(setup.get("with", {}).get("global-json-file") == "global.json", + "Static analysis must use the pinned SDK from global.json.") + restore = named_step(static_steps, "Restore locked") + restore_run = str(restore.get("run", "")) + require(restore.get("shell") == "pwsh" + and "dotnet restore SmartPipe.Core.slnx --locked-mode" in restore_run + and NATIVE_FAIL_FAST_GUARD in restore_run, + "Static analysis must perform a fail-closed locked restore.") + build = named_step(static_steps, "Build static analysis") + build_run = str(build.get("run", "")) + require(build.get("shell") == "pwsh" + and "dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror" in build_run + and NATIVE_FAIL_FAST_GUARD in build_run, + "Static analysis must use the existing analyzers with a fail-closed warnings-as-errors build.") def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: @@ -728,13 +746,13 @@ def assert_consumer_contract() -> None: def validate(documents: dict[str, dict]) -> None: reusable = documents["reusable-release-validation.yml"] ci = documents["ci.yml"] - codeql = documents["codeql.yml"] + static_analysis = documents["codeql.yml"] dependency_review = documents["dependency-review.yml"] publish = documents["publish-nuget.yml"] for workflow_name, workflow in ( ("ci.yml", ci), - ("codeql.yml", codeql), + ("codeql.yml", static_analysis), ("dependency-review.yml", dependency_review), ): branches = workflow.get("on", {}).get("pull_request", {}).get("branches", []) @@ -1051,22 +1069,9 @@ def validate(documents: dict[str, dict]) -> None: CLEANUP_PULL_REQUEST_GUARD, cleanup_nuget=True, ) - assert_cleanup_job( - codeql, - "codeql.yml", - ["analyze"], - CLEANUP_PULL_REQUEST_GUARD, - cleanup_nuget=True, - ) assert_repository_security_audit_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") - assert_nuget_isolation_contract(codeql, "codeql.yml") - - codeql_job = codeql["jobs"].get("analyze") - require(isinstance(codeql_job, dict), "CodeQL must define the analyze job.") - require_runner_expression(codeql_job, CODEQL_RUNNER, "CodeQL analyze") - require_same_repository_pr_guard(codeql_job, "CodeQL analyze") - assert_codeql_resource_contract(codeql_job) + assert_static_analysis_contract(static_analysis) all_runs = windows_runs + hosting_runs + reusable_runs filtered = [command for command in all_runs if "--filter-class" in command or "--filter-query" in command] @@ -1259,7 +1264,6 @@ def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: ("ci.yml", "baseline-contract-windows"), ("reusable-release-validation.yml", "build-test-pack"), ("reusable-release-validation.yml", "health-checks-concurrency"), - ("codeql.yml", "analyze"), ) for workflow_name, job_name in lanes: documents[workflow_name]["jobs"][job_name]["runs-on"] = "windows-latest" @@ -1292,36 +1296,10 @@ def _make_ci_baseline_always_self_hosted(documents: dict[str, dict]) -> None: documents["ci.yml"]["jobs"]["baseline-contract-windows"]["runs-on"] = SELF_HOSTED_WINDOWS -def _make_codeql_always_self_hosted(documents: dict[str, dict]) -> None: +def _make_static_analysis_always_self_hosted(documents: dict[str, dict]) -> None: documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS -def _remove_codeql_resource_cap(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"].pop("ram", None) - - -def _make_codeql_resource_cap_unconditional(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"]["ram"] = "16384" - analysis["with"]["threads"] = "2" - - -def _make_codeql_resource_cap_linux_wide(documents: dict[str, dict]) -> None: - analysis = named_step( - documents["codeql.yml"]["jobs"]["analyze"]["steps"], - "Perform CodeQL Analysis", - ) - analysis["with"]["ram"] = str(analysis["with"]["ram"]).replace("|| ''", "|| '16384'") - analysis["with"]["threads"] = str(analysis["with"]["threads"]).replace("|| ''", "|| '2'") - - def _remove_nuget_isolation(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["env"].pop("NUGET_PACKAGES", None) @@ -1757,25 +1735,10 @@ def main() -> int: ) assert_mutation_rejected( documents, - _make_codeql_always_self_hosted, - "event-aware runner expression", - ) - assert_mutation_rejected( - documents, - _remove_codeql_resource_cap, - "CodeQL analyze resource cap", - ) - assert_mutation_rejected( - documents, - _make_codeql_resource_cap_unconditional, - "CodeQL analyze resource cap", - ) - assert_mutation_rejected( - documents, - _make_codeql_resource_cap_linux_wide, - "CodeQL analyze resource cap", + _make_static_analysis_always_self_hosted, + "must not retain self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml", "reusable-release-validation.yml"): + for workflow_name in ("ci.yml", "reusable-release-validation.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_nuget_isolation(docs, name), @@ -1845,7 +1808,7 @@ def main() -> int: _remove_ci_cleanup_job, "must define cleanup-self-hosted", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), @@ -1856,13 +1819,13 @@ def main() -> int: _make_ci_cleanup_delete_workspace_root, "must not delete the workspace root", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_direct_target_guard(docs, name), f"{workflow_name} cleanup must reject direct target reparse points", ) - for workflow_name in ("ci.yml", "codeql.yml"): + for workflow_name in ("ci.yml",): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_cleanup_nuget_target(docs, name), diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs index 177b94d..8edf644 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs @@ -111,6 +111,22 @@ public async Task Capture_PersistsExactCaptureAndWorkflowCommitIdentity() Assert.Null(root["repository"]!["commitSha"]); } + [Fact] + public async Task Capture_AcceptsAndPersistsCurrentWorkflowNames() + { + using var scenario = new BaselineScenario(); + + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var root = JsonNode.Parse(await File.ReadAllTextAsync( + scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); + + Assert.Equal( + ["CI", "Hosted .NET static analysis", "Repository security audit"], + root["repository"]!["requiredWorkflows"]!.AsArray() + .Select(workflow => workflow!["name"]!.GetValue()) + .Order(StringComparer.Ordinal)); + } + [Fact] public async Task DescendantGovernanceHead_VerifiesByCaptureCommitAncestry() { @@ -267,6 +283,18 @@ public async Task Capture_RejectsDuplicateSuccessfulWorkflowEvidenceAsAmbiguous( Assert.Contains("exactly one", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task Capture_RejectsHistoricalSecurityWorkflowName() + { + using var scenario = new BaselineScenario(); + scenario.WriteWorkflowEvidence("historical-security-name"); + + var exception = await Assert.ThrowsAsync( + () => scenario.CaptureAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("Repository security audit", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task FailedCapture_DoesNotReplaceExistingBaseline() { @@ -309,6 +337,62 @@ public async Task ManifestMutation_Fails() Assert.Contains(result.Diagnostics, item => item.Code == "SPB001"); } + [Fact] + public async Task HistoricalManifestWorkflowNamesReachNormalIntegrityDiagnostics() + { + using var scenario = new BaselineScenario(); + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var manifest = BaselineManifestSerializer.Deserialize( + await File.ReadAllTextAsync(scenario.ManifestPath, TestContext.Current.CancellationToken)); + var historicalManifest = manifest with + { + Repository = manifest.Repository with + { + RequiredWorkflows = manifest.Repository.RequiredWorkflows + .Select(workflow => workflow with + { + Name = workflow.Name switch + { + "Hosted .NET static analysis" => "CodeQL", + "Repository security audit" => "Dependency Review", + _ => workflow.Name, + }, + }) + .ToArray(), + }, + }; + await BaselineManifestSerializer.WriteAsync( + scenario.ManifestPath, historicalManifest, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync( + Path.Combine(scenario.BaselinePath, "baseline-report.md"), + BaselineReport.Create(historicalManifest), TestContext.Current.CancellationToken); + await File.AppendAllTextAsync(scenario.PublicApiPath, "\nHistorical.Api", TestContext.Current.CancellationToken); + + var result = await scenario.VerifyAsync(); + + Assert.DoesNotContain(result.Diagnostics, item => item.Code == "SPB001"); + Assert.Contains(result.Diagnostics, item => item.Code == "SPB014"); + } + + [Theory] + [InlineData("CodeQL")] + [InlineData("Unexpected workflow")] + public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string replacementName) + { + using var scenario = new BaselineScenario(); + await scenario.CaptureAsync(TestContext.Current.CancellationToken); + var root = JsonNode.Parse(await File.ReadAllTextAsync( + scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); + var workflows = root["repository"]!["requiredWorkflows"]!.AsArray(); + workflows.Single(workflow => workflow!["name"]!.GetValue() == "Hosted .NET static analysis")!["name"] = replacementName; + await File.WriteAllTextAsync( + scenario.ManifestPath, root.ToJsonString(), TestContext.Current.CancellationToken); + + var result = await scenario.VerifyAsync(); + + Assert.Contains(result.Diagnostics, item => item.Code == "SPB001"); + } + [Fact] public async Task PackageByteMutation_FailsBeforeParsing() { @@ -709,6 +793,9 @@ public void WriteWorkflowEvidence(string mutation) var ciSha = mutation == "mixed-sha" ? new string('a', 40) : Sha; var ciStatus = mutation == "pending" ? "in_progress" : "completed"; var ciConclusion = mutation == "pending" ? string.Empty : mutation == "failed" ? "failure" : "success"; + var securityWorkflowName = mutation == "historical-security-name" + ? "Dependency Review" + : "Repository security audit"; var extra = mutation switch { "extra-pending" => $$""" @@ -728,8 +815,8 @@ public void WriteWorkflowEvidence(string mutation) var evidence = $$""" [ {"databaseId":1,"workflowName":"CI","headSha":"{{ciSha}}","status":"{{ciStatus}}","conclusion":"{{ciConclusion}}","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/1","event":"push","createdAt":"2026-07-17T00:00:00Z"}, - {"databaseId":2,"workflowName":"CodeQL","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, - {"databaseId":3,"workflowName":"Dependency Review","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/3","event":"pull_request","createdAt":"2026-07-17T00:02:00Z"}{{extra}} + {"databaseId":2,"workflowName":"Hosted .NET static analysis","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/2","event":"push","createdAt":"2026-07-17T00:01:00Z"}, + {"databaseId":3,"workflowName":"{{securityWorkflowName}}","headSha":"{{Sha}}","status":"completed","conclusion":"success","url":"https://github.com/MrFr3di/SmartPipe-Core/actions/runs/3","event":"pull_request","createdAt":"2026-07-17T00:02:00Z"}{{extra}} ] """; File.WriteAllText(WorkflowEvidencePath, evidence); From c27b9a344bcfbbc9e87eef0c04c2d8ba4526eda9 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 01:57:24 +0500 Subject: [PATCH 5/8] fix(ci): fail closed on ambiguous runner listeners --- docs/contributing.md | 5 +- eng/runner/install-runner.ps1 | 1 + eng/runner/runner-safety.ps1 | 151 ++++++++++++++++++++++++---- eng/runner/uninstall-runner.ps1 | 1 + eng/tests/runner-contract.Tests.ps1 | 18 ++++ 5 files changed, 155 insertions(+), 21 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index bb68ac0..a82189e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -113,7 +113,10 @@ writable persistent directory without granting access to `C:\Program Files\dotnet`. The hook entry is `ACTIONS_RUNNER_HOOK_JOB_STARTED`; upgrades remove the legacy `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` entry and hook copy before writing the new -owned state. +owned state. Before any file, label, stop, or restart mutation, every +`Runner.Listener.exe` must be classifiable to this exact root. Missing or +unreadable process metadata and listeners belonging to another root fail closed +with their PIDs; ambiguous listeners are never stopped automatically. The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 index 7dfbd4e..9931e1b 100644 --- a/eng/runner/install-runner.ps1 +++ b/eng/runner/install-runner.ps1 @@ -33,6 +33,7 @@ try { Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath if ($Uninstall) { $environmentPath = Join-Path $runner '.env' diff --git a/eng/runner/runner-safety.ps1 b/eng/runner/runner-safety.ps1 index 241cda8..daf99a3 100644 --- a/eng/runner/runner-safety.ps1 +++ b/eng/runner/runner-safety.ps1 @@ -317,6 +317,48 @@ function Assert-SmartPipeCanonicalRemote { throw "Workspace origin remote is not MrFr3di/SmartPipe-Core: $Workspace" } +function Get-SmartPipeListenerClassification { + param( + [Parameter(Mandatory = $true)] + [object] $Listener, + + [Parameter(Mandatory = $true)] + [string] $Root + ) + + $runnerRoot = Get-SmartPipeFullPath -Path $Root + $executablePath = '' + $executableReadable = $true + try { + $executablePath = [string]$Listener.ExecutablePath + } + catch { + $executableReadable = $false + } + + if (-not $executableReadable -or [string]::IsNullOrWhiteSpace($executablePath)) { + return 'unclassified' + } + try { + if (-not [IO.Path]::IsPathFullyQualified($executablePath)) { + return 'unclassified' + } + } + catch { + return 'unclassified' + } + + try { + if (Test-SmartPipeContainedPath -Path $executablePath -Boundary $runnerRoot) { + return 'exact' + } + return 'outside' + } + catch { + return 'unclassified' + } +} + function Get-SmartPipeListenerProcesses { param( [Parameter(Mandatory = $true)] @@ -325,41 +367,110 @@ function Get-SmartPipeListenerProcesses { [string] $FixturePath = '' ) + $listenerRecords = @() if (-not [string]::IsNullOrWhiteSpace($FixturePath)) { if (-not (Test-Path -LiteralPath $FixturePath -PathType Leaf)) { return @() } $text = (Get-Content -LiteralPath $FixturePath -Raw -ErrorAction Stop).Trim() - $count = 0 - if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { - throw "Invalid listener fixture state: $FixturePath" + $runnerRoot = Get-SmartPipeFullPath -Path $Root + $fixtureExecutable = Join-Path $runnerRoot 'bin\Runner.Listener.exe' + if ($text -eq 'unclassified-duplicate') { + $listenerRecords = @( + [pscustomobject]@{ + ProcessId = 4101 + Name = 'Runner.Listener.exe' + ExecutablePath = $fixtureExecutable + CommandLine = $fixtureExecutable + }, + [pscustomobject]@{ + ProcessId = 4102 + Name = 'Runner.Listener.exe' + ExecutablePath = $null + CommandLine = "-RunnerRoot $runnerRoot" + } + ) } + else { + $count = 0 + if (-not [int]::TryParse($text, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture, [ref]$count) -or $count -lt 0) { + throw "Invalid listener fixture state: $FixturePath" + } - $fixtureListeners = [Collections.Generic.List[object]]::new() - for ($index = 1; $index -le $count; $index++) { - [void]$fixtureListeners.Add([pscustomobject]@{ - ProcessId = 0 - Name = 'Runner.Listener.fixture' - CommandLine = $Root + $fixtureListeners = [Collections.Generic.List[object]]::new() + for ($index = 1; $index -le $count; $index++) { + [void]$fixtureListeners.Add([pscustomobject]@{ + ProcessId = 0 + Name = 'Runner.Listener.exe' + ExecutablePath = $fixtureExecutable + CommandLine = $fixtureExecutable + }) + } + $listenerRecords = @($fixtureListeners) + } + } + else { + try { + $listenerRecords = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') }) } - return @($fixtureListeners) + catch { + if ($IsWindows) { + throw "Unable to inspect listener processes for $Root." + } + return @() + } } - try { - $escapedRoot = [Regex]::Escape((Get-SmartPipeFullPath -Path $Root)) - return @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Where-Object { - $_.Name -in @('Runner.Listener.exe', 'Runner.Listener') -and - $_.CommandLine -match $escapedRoot - }) + $exactListeners = [Collections.Generic.List[object]]::new() + $unclassifiedIds = [Collections.Generic.List[string]]::new() + $outsideIds = [Collections.Generic.List[string]]::new() + foreach ($listener in $listenerRecords) { + $processId = $null + try { + $processId = $listener.ProcessId + } + catch { + $processId = $null + } + $processIdText = if ($null -eq $processId -or [string]::IsNullOrWhiteSpace([string]$processId)) { 'unknown' } else { [string]$processId } + $classification = Get-SmartPipeListenerClassification -Listener $listener -Root $Root + if ($classification -eq 'exact') { + [void]$exactListeners.Add($listener) + } + elseif ($classification -eq 'outside') { + [void]$outsideIds.Add($processIdText) + } + else { + [void]$unclassifiedIds.Add($processIdText) + } } - catch { - if ($IsWindows) { - throw "Unable to inspect listener processes for $Root." + + if ($unclassifiedIds.Count -gt 0 -or $outsideIds.Count -gt 0) { + $details = [Collections.Generic.List[string]]::new() + if ($unclassifiedIds.Count -gt 0) { + [void]$details.Add("unclassified Runner.Listener PID(s): $($unclassifiedIds -join ', ')") } - return @() + if ($outsideIds.Count -gt 0) { + [void]$details.Add("Runner.Listener outside '$Root' PID(s): $($outsideIds -join ', ')") + } + throw "Runner listener safety check failed for '$Root': $($details -join '; '). No listener was stopped." } + + return @($exactListeners) +} + +function Assert-SmartPipeListenerSafety { + param( + [Parameter(Mandatory = $true)] + [string] $Root, + + [string] $FixturePath = '' + ) + + $null = @(Get-SmartPipeListenerProcesses -Root $Root -FixturePath $FixturePath) } function Stop-SmartPipeListenerProcesses { diff --git a/eng/runner/uninstall-runner.ps1 b/eng/runner/uninstall-runner.ps1 index 76e3484..032485a 100644 --- a/eng/runner/uninstall-runner.ps1 +++ b/eng/runner/uninstall-runner.ps1 @@ -28,6 +28,7 @@ try { $resolvedRunnerName = Resolve-SmartPipeRunnerName -Root $runner -RequestedName $RunnerName Assert-SmartPipeActionsRunsIdle -Repository $Repository -GhPath $GhPath $remoteRunner = Assert-SmartPipeRemoteRunnerIdle -Repository $Repository -RunnerName $resolvedRunnerName -GhPath $GhPath + Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath $environmentPath = Join-Path $runner '.env' Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index cd01c01..2573dd0 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -279,6 +279,24 @@ ACTIONS_RUNNER_HOOK_JOB_COMPLETED=C:\legacy\smartpipe-post-job-cleanup.ps1 Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-job-start-cleanup.ps1')) -Message 'Installer must copy the job-start hook.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $runnerRoot 'hooks\smartpipe-post-job-cleanup.ps1'))) -Message 'Installer must remove the legacy hook copy.' + $environmentBeforeAmbiguous = Get-Content -LiteralPath $environment -Raw + $labelsBeforeAmbiguous = Get-Content -LiteralPath $labelState -Raw + 'unclassified-duplicate' | Set-Content -LiteralPath $listenerFixture -NoNewline + $ambiguousInstall = Invoke-RunnerScript -ScriptPath $installScript -Arguments @( + '-RunnerRoot', $runnerRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-RunnerName', 'SmartPipe-Runner', + '-GhPath', $runnerGh, + '-ListenerFixturePath', $listenerFixture, + '-AllowTestRoot' + ) + Assert-RunnerTrue -Condition ($ambiguousInstall.ExitCode -ne 0) -Message "Installer must refuse an unclassified duplicate before mutation. $($ambiguousInstall.Output)" + Assert-RunnerTrue -Condition ($ambiguousInstall.Output -match '4102') -Message "Unclassified listener diagnostics must report the exact PID. $($ambiguousInstall.Output)" + Assert-RunnerEqual -Actual ((Get-Content -LiteralPath $listenerFixture -Raw).Trim()) -Expected 'unclassified-duplicate' -Message 'Unclassified duplicate refusal must not stop or rewrite the listener fixture.' + Assert-RunnerEqual -Actual (Get-Content -LiteralPath $environment -Raw) -Expected $environmentBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede environment mutation.' + Assert-RunnerEqual -Actual (Get-Content -LiteralPath $labelState -Raw) -Expected $labelsBeforeAmbiguous -Message 'Unclassified duplicate refusal must precede label mutation.' + '1' | Set-Content -LiteralPath $listenerFixture -NoNewline + $uninstall = Invoke-RunnerScript -ScriptPath $uninstallScript -Arguments @( '-RunnerRoot', $runnerRoot, '-Repository', 'MrFr3di/SmartPipe-Core', From 496deffb892337bf667d8cb06240029f4d76c12e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:08:56 +0500 Subject: [PATCH 6/8] fix(ci): recreate workspace after pre-job cleanup --- docs/contributing.md | 10 ++++++---- eng/runner/job-start-cleanup.ps1 | 12 ++++++++++++ eng/tests/runner-contract.Tests.ps1 | 7 ++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index a82189e..fb8205d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -121,11 +121,13 @@ with their PIDs; ambiguous listeners are never stopped automatically. The job-start hook accepts only `MrFr3di/SmartPipe-Core`, verifies the checkout remote, and canonicalizes every target beneath the dedicated runner root. It runs before the next job starts, after the runner has completed the previous -job's process cleanup, and removes the exact prior checkout plus the known +job's process cleanup, removes the exact prior checkout, and recreates its +empty workspace directory before the next checkout. It also removes the known `SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below -`RUNNER_TEMP`. Missing targets are successful. Any outside path, broad root, -reparse point, unsafe repository, or deletion error fails closed before -removal; the existing workflow cleanup jobs remain as defense in depth. +`RUNNER_TEMP`. Missing temp targets are successful. Any outside path, broad +root, reparse point, unsafe repository, non-empty recreation, or deletion error +fails closed before removal; the existing workflow cleanup jobs remain as +defense in depth. For a compact, transition-only pull-request view: diff --git a/eng/runner/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 index 9cc69fa..e153452 100644 --- a/eng/runner/job-start-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -37,10 +37,22 @@ try { Assert-SmartPipeWorkspaceRepository -Workspace $workspace [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) } + elseif (Test-Path -LiteralPath $workspace) { + throw "Workspace path is not a directory: $workspace" + } else { Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner } + New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + throw "Workspace directory was not created: $workspace" + } + if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -ne 0) { + throw "Workspace directory is not empty after cleanup: $workspace" + } + if (-not [string]::IsNullOrWhiteSpace($TempRoot)) { $temp = Get-SmartPipeFullPath -Path $TempRoot if (-not (Test-SmartPipeContainedPath -Path $temp -Boundary $runner)) { diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 2573dd0..75c3d84 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -93,7 +93,10 @@ try { '-AllowTestRoot' ) Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" - Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath $workspace)) -Message 'The exact checkout must be removed.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $workspace -PathType Container) -Message 'The exact workspace directory must be recreated.' + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'The recreated workspace must be empty.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace '.git'))) -Message 'The recreated workspace must not retain .git.' + Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $workspace 'output.txt'))) -Message 'The recreated workspace must not retain stale files.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $toolRoot 'preserve.txt')) -Message '_tool must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $sibling 'preserve.txt')) -Message 'Sibling repositories must be preserved.' Assert-RunnerTrue -Condition (Test-Path -LiteralPath (Join-Path $tempRoot 'unrelated.tmp')) -Message 'Unrelated temp files must be preserved.' @@ -109,6 +112,8 @@ try { '-AllowTestRoot' ) Assert-RunnerEqual -Actual $absent.ExitCode -Expected 0 -Message 'Absent cleanup targets must be successful.' + Assert-RunnerTrue -Condition (Test-Path -LiteralPath $absentWorkspace -PathType Container) -Message 'An absent workspace must be recreated.' + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $absentWorkspace -Force).Count -Expected 0 -Message 'A recreated absent workspace must be empty.' New-Item -ItemType Directory -Path $workspace, (Join-Path $workspace '.git') -Force | Out-Null @' From aec9921b30e2cbdcce548f975c8a2829cecdbc63 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:21:39 +0500 Subject: [PATCH 7/8] fix(ci): accept empty pre-job workspace --- docs/contributing.md | 4 +++- eng/runner/job-start-cleanup.ps1 | 11 ++++++++--- eng/tests/runner-contract.Tests.ps1 | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index fb8205d..4e3bfc5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -126,7 +126,9 @@ empty workspace directory before the next checkout. It also removes the known `SmartPipe.Core`, `SmartPipe-Core`, `CodeQL`, and `codeql` directories below `RUNNER_TEMP`. Missing temp targets are successful. Any outside path, broad root, reparse point, unsafe repository, non-empty recreation, or deletion error -fails closed before removal; the existing workflow cleanup jobs remain as +fails closed before removal. An existing empty workspace is accepted +idempotently; any non-empty workspace must pass the exact repository/origin +authorization before removal. The existing workflow cleanup jobs remain as defense in depth. For a compact, transition-only pull-request view: diff --git a/eng/runner/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 index e153452..e451cba 100644 --- a/eng/runner/job-start-cleanup.ps1 +++ b/eng/runner/job-start-cleanup.ps1 @@ -34,8 +34,11 @@ try { } if (Test-Path -LiteralPath $workspace -PathType Container) { - Assert-SmartPipeWorkspaceRepository -Workspace $workspace - [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner + if (@(Get-ChildItem -LiteralPath $workspace -Force -ErrorAction Stop).Count -gt 0) { + Assert-SmartPipeWorkspaceRepository -Workspace $workspace + [void](Remove-SmartPipeCleanupTarget -Path $workspace -Boundary $runner -AllowBoundary) + } } elseif (Test-Path -LiteralPath $workspace) { throw "Workspace path is not a directory: $workspace" @@ -44,7 +47,9 @@ try { Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner } - New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + New-Item -ItemType Directory -Path $workspace -ErrorAction Stop | Out-Null + } Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { throw "Workspace directory was not created: $workspace" diff --git a/eng/tests/runner-contract.Tests.ps1 b/eng/tests/runner-contract.Tests.ps1 index 75c3d84..b1c16bb 100644 --- a/eng/tests/runner-contract.Tests.ps1 +++ b/eng/tests/runner-contract.Tests.ps1 @@ -103,6 +103,16 @@ try { Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'SmartPipe.Core'))) -Message 'Known SmartPipe temp must be removed.' Assert-RunnerTrue -Condition (-not (Test-Path -LiteralPath (Join-Path $tempRoot 'CodeQL'))) -Message 'Known CodeQL temp must be removed.' + $emptyCleanup = Invoke-RunnerScript -ScriptPath $jobStartScript -WorkingDirectory $workspace -Arguments @( + '-RunnerRoot', $runnerRoot, + '-WorkspaceRoot', $workspace, + '-TempRoot', $tempRoot, + '-Repository', 'MrFr3di/SmartPipe-Core', + '-AllowTestRoot' + ) + Assert-RunnerEqual -Actual $emptyCleanup.ExitCode -Expected 0 -Message "An existing empty workspace must be idempotently clean. $($emptyCleanup.Output)" + Assert-RunnerEqual -Actual @(Get-ChildItem -LiteralPath $workspace -Force).Count -Expected 0 -Message 'An idempotent empty workspace must remain empty.' + $absentWorkspace = Join-Path $runnerRoot '_work\SmartPipe.Core\absent' $absent = Invoke-RunnerScript -ScriptPath $jobStartScript -Arguments @( '-RunnerRoot', $runnerRoot, From 4be8f98700b974b1bef3dc002bba94df8aecd9ee Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Tue, 25 Aug 2026 02:55:43 +0500 Subject: [PATCH 8/8] fix(ci): avoid unused PR artifact uploads --- .../workflows/reusable-release-validation.yml | 1 + docs/architecture/package-infrastructure.md | 5 +++- eng/tests/workflow_contract_tests.py | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 6dbcf82..1232a5f 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -260,6 +260,7 @@ jobs: if ($exitCode -ne 0) { exit $exitCode } - name: Upload immutable packages and reports + if: github.event_name != 'pull_request' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.artifact-name }} diff --git a/docs/architecture/package-infrastructure.md b/docs/architecture/package-infrastructure.md index 56071a2..86ec304 100644 --- a/docs/architecture/package-infrastructure.md +++ b/docs/architecture/package-infrastructure.md @@ -74,7 +74,10 @@ The profile replaces duplicate central-package and project checks only. Packing, baseline provisioning/offline verification, package metadata and ownership, consumers, audit, and artifact upload remain specialized workflow gates. Consumer artifacts upload `result.json`; retained bounded logs stay in -the local job workspace. +the local job workspace. Pull-request validation skips only the shared artifact +upload to avoid account-level storage quota failures; package/report generation +and all preceding gates remain required. Push, dispatch, and release validation +continue to require the fail-closed artifact for publish consumption. ## Agent context and exact-tree evidence diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 5b5926f..4411a9d 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -965,6 +965,8 @@ def validate(documents: dict[str, dict]) -> None: and "--report artifacts/audit/vulnerable.json" in audit_policy_run, "Reusable validation must enforce the direct production audit policy from the vulnerable JSON report.") upload = named_step(reusable_steps, "Upload immutable packages and reports") + require(upload.get("if") == "github.event_name != 'pull_request'", + "Reusable validation artifact upload must skip only pull_request events and remain required for non-PR events.") require(upload.get("with", {}).get("name") == "${{ inputs.artifact-name }}", "Reusable validation must upload the caller-selected artifact name.") upload_path = str(upload.get("with", {}).get("path", "")) @@ -1509,6 +1511,22 @@ def _duplicate_upload(documents: dict[str, dict]) -> None: job_steps.append(copy.deepcopy(named_step(job_steps, "Upload immutable packages and reports"))) +def _remove_upload_event_guard(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload.pop("if", None) + + +def _restrict_upload_to_push(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload["if"] = "github.event_name == 'push'" + + def _hardcode_publish_package(documents: dict[str, dict]) -> None: publish_steps = documents["publish-nuget.yml"]["jobs"]["publish"]["steps"] push = named_step(publish_steps, "Publish packages in dependency order") @@ -1856,6 +1874,16 @@ def main() -> int: _duplicate_upload, "exactly one step named 'Upload immutable packages and reports'", ) + assert_mutation_rejected( + documents, + _remove_upload_event_guard, + "skip only pull_request events and remain required for non-PR events", + ) + assert_mutation_rejected( + documents, + _restrict_upload_to_push, + "skip only pull_request events and remain required for non-PR events", + ) assert_mutation_rejected( documents, _hardcode_publish_package,