From 8dce0ddfe1c30ab864eb0b89cf9bcfafdddc2f05 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Thu, 27 Aug 2026 21:36:32 +0500 Subject: [PATCH 1/2] ci: retire SmartPipe self-hosted runner --- .github/workflows/ci.yml | 61 +- .github/workflows/codeql.yml | 34 +- .github/workflows/dependency-review.yml | 48 +- .../workflows/reusable-release-validation.yml | 7 +- docs/contributing.md | 75 +- .../2.2.0-branch-and-review-policy.md | 2 +- .../Commands/BaselineCaptureService.cs | 4 +- .../Commands/BaselineVerificationService.cs | 18 +- eng/baselines/README.md | 6 +- eng/runner/install-runner.ps1 | 95 -- eng/runner/job-start-cleanup.ps1 | 82 -- eng/runner/monitor-pr.ps1 | 145 --- eng/runner/runner-safety.ps1 | 848 ------------------ eng/runner/uninstall-runner.ps1 | 55 -- eng/tests/runner-contract.Tests.ps1 | 373 -------- eng/tests/workflow-contract.Tests.ps1 | 6 - eng/tests/workflow_contract_tests.py | 477 ++++------ .../Commands/BaselineOrchestrationTests.cs | 43 +- 18 files changed, 252 insertions(+), 2127 deletions(-) delete mode 100644 eng/runner/install-runner.ps1 delete mode 100644 eng/runner/job-start-cleanup.ps1 delete mode 100644 eng/runner/monitor-pr.ps1 delete mode 100644 eng/runner/runner-safety.ps1 delete mode 100644 eng/runner/uninstall-runner.ps1 delete mode 100644 eng/tests/runner-contract.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19fe5fa..befa3cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ permissions: contents: read env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: validation: @@ -36,16 +36,16 @@ jobs: permissions: contents: read with: - runner-labels: ${{ github.event_name == 'pull_request' && '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' || '["ubuntu-latest"]' }} + runner-labels: ${{ github.event_name == 'pull_request' && '["windows-latest"]' || '["ubuntu-latest"]' }} hosting-integration: - name: Hosting integration (${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}) + name: Hosting integration (${{ matrix.os == 'windows-latest' && 'Windows' || 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 }} + runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false - matrix: ${{ fromJSON(github.event_name == 'pull_request' && '{"os":["self-hosted"]}' || '{"os":["ubuntu-latest","windows-latest"]}') }} + matrix: ${{ fromJSON(github.event_name == 'pull_request' && '{"os":["windows-latest"]}' || '{"os":["ubuntu-latest","windows-latest"]}') }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -56,6 +56,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore Hosting integration tests run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode @@ -68,7 +70,7 @@ jobs: json-file-windows: 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' }} + runs-on: windows-latest timeout-minutes: 20 steps: @@ -80,6 +82,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -124,7 +128,7 @@ jobs: baseline-contract-windows: name: Baseline contract (Windows) 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' }} + runs-on: windows-latest timeout-minutes: 20 steps: @@ -137,6 +141,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true @@ -156,7 +162,7 @@ jobs: 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] + runs-on: windows-latest timeout-minutes: 45 steps: - name: Validate diagnostic inputs @@ -190,6 +196,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -235,40 +243,3 @@ jobs: 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, smartpipe-cleanup-v1] - steps: - - name: Cleanup generated outputs - 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 - } - } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5327eed..1889dcd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: Hosted .NET static analysis +name: CodeQL on: push: @@ -10,10 +10,14 @@ on: permissions: contents: read + security-events: write + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: analyze: - name: Hosted .NET static analysis + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -24,15 +28,19 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: csharp - - name: Restore locked - shell: pwsh - run: | - dotnet restore SmartPipe.Core.slnx --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Build static analysis - shell: pwsh - run: | - dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - 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' || '' }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0d77c8e..153a0a4 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,4 +1,4 @@ -name: Repository security audit +name: Dependency Review on: pull_request: @@ -6,53 +6,15 @@ on: permissions: contents: read + pull-requests: read jobs: - repository-security-audit: - name: Repository security audit - if: github.event.pull_request.head.repo.full_name == github.repository + dependency-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Setup .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - global-json-file: global.json - - - 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: | - 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 } + - name: Dependency review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 1232a5f..2d19453 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -23,7 +23,7 @@ permissions: contents: read env: - NUGET_PACKAGES: ${{ github.event_name == 'pull_request' && format('{0}/.nuget/packages', github.workspace) || '' }} + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages jobs: build-test-pack: @@ -40,6 +40,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode @@ -264,6 +266,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.artifact-name }} + retention-days: ${{ inputs.artifact-name == 'packages' && 7 || 90 }} path: | artifacts/packages artifacts/consumers/**/result.json @@ -284,6 +287,8 @@ jobs: uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore locked run: dotnet restore SmartPipe.Core.slnx --locked-mode diff --git a/docs/contributing.md b/docs/contributing.md index 4e3bfc5..6174db4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -80,68 +80,18 @@ 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 +## Hosted CI 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. +GitHub-hosted runners provide the CI environment. Same-repository pull requests +run validation and Windows-specific lanes on `windows-latest`; push and manual +dispatch runs keep the Hosting integration Linux and Windows matrix. CodeQL and +Dependency Review use their official public-repository workflows. -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 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. 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 -runs before the next job starts, after the runner has completed the previous -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 temp targets are successful. Any outside path, broad -root, reparse point, unsafe repository, non-empty recreation, or deletion error -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: - -```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. +Restore-heavy jobs set `NUGET_PACKAGES` below `GITHUB_WORKSPACE` and use the +built-in `actions/setup-dotnet` cache keyed by `**/packages.lock.json`. Build +outputs, credentials, and secrets are never cached. Generic CI package +artifacts are retained for seven days; versioned release artifacts retain the +repository's normal release retention. The optional diagnostic dispatch runs one exact commit and one internal consumer scenario without changing normal push or pull-request behavior: @@ -158,8 +108,3 @@ 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/docs/governance/2.2.0-branch-and-review-policy.md b/docs/governance/2.2.0-branch-and-review-policy.md index 4ff6d50..d81303c 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, Hosted .NET static analysis, Repository security audit, baseline contract | +| Checks | `CI / validation`, Windows JSON lane, CodeQL, Dependency Review, 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 d3b38f1..0b34c76 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineCaptureService.cs @@ -328,8 +328,8 @@ private static async Task> ReadWorkflowEvidenceA foreach (var requiredName in new[] { "CI", - "Hosted .NET static analysis", - "Repository security audit", + "CodeQL", + "Dependency Review", }) { var successful = runs.Where(run => diff --git a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs index ec85418..a5c50a1 100644 --- a/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs +++ b/eng/SmartPipe.RepositoryChecks/Commands/BaselineVerificationService.cs @@ -45,25 +45,18 @@ 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[] HistoricalManifestWorkflowNames = + private static readonly string[] ManifestWorkflowNames = [ "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"]), - ("Hosted .NET static analysis", ".github/workflows/codeql.yml", ["push", "pull_request"]), - ("Repository security audit", ".github/workflows/dependency-review.yml", ["pull_request"]), + ("CodeQL", ".github/workflows/codeql.yml", ["push", "pull_request"]), + ("Dependency Review", ".github/workflows/dependency-review.yml", ["pull_request"]), ]; private readonly IProcessRunner _processRunner; @@ -132,11 +125,10 @@ internal async Task VerifyAsync( var workflowNames = manifest.Repository.RequiredWorkflows .Select(static workflow => workflow.Name) .ToHashSet(StringComparer.Ordinal); - if (!workflowNames.SetEquals(HistoricalManifestWorkflowNames) - && !workflowNames.SetEquals(CurrentManifestWorkflowNames)) + if (!workflowNames.SetEquals(ManifestWorkflowNames)) { 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."); + "Manifest workflow evidence must contain exactly CI, CodeQL, and Dependency Review."); } // Resolve and de-alias every referenced path before any package, process, or repository work. diff --git a/eng/baselines/README.md b/eng/baselines/README.md index 203ac38..91fd89f 100644 --- a/eng/baselines/README.md +++ b/eng/baselines/README.md @@ -26,9 +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, Hosted .NET static analysis, or Repository security audit workflow policy. +- `SPB016`: required release branch missing from CI, CodeQL, or Dependency Review 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, 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. +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 requires those current check names exactly and persists those literal names. Offline verification accepts only the complete historical workflow set (`CI`, `CodeQL`, `Dependency Review`); mixed or extra workflow identities fail closed. diff --git a/eng/runner/install-runner.ps1 b/eng/runner/install-runner.ps1 deleted file mode 100644 index 9931e1b..0000000 --- a/eng/runner/install-runner.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -[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 - Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath - - if ($Uninstall) { - $environmentPath = Join-Path $runner '.env' - Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath - $hookDirectory = Join-Path $runner 'hooks' - 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 - 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 '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)) { - 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 - - $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 - - $environmentPath = Join-Path $runner '.env' - $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) { - 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/job-start-cleanup.ps1 b/eng/runner/job-start-cleanup.ps1 deleted file mode 100644 index e451cba..0000000 --- a/eng/runner/job-start-cleanup.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -[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 - Set-Location -LiteralPath $runner - [Environment]::CurrentDirectory = $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-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" - } - else { - Assert-SmartPipeNoReparsePath -Path $workspace -Boundary $runner - } - - 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" - } - 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)) { - 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 job-start cleanup completed.' -} -catch { - $errorText = [string]$_.Exception.Message - Write-Error -Message $errorText - exit 1 -} diff --git a/eng/runner/monitor-pr.ps1 b/eng/runner/monitor-pr.ps1 deleted file mode 100644 index bad8378..0000000 --- a/eng/runner/monitor-pr.ps1 +++ /dev/null @@ -1,145 +0,0 @@ -[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/runner-safety.ps1 b/eng/runner/runner-safety.ps1 deleted file mode 100644 index daf99a3..0000000 --- a/eng/runner/runner-safety.ps1 +++ /dev/null @@ -1,848 +0,0 @@ -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-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)] - [string] $Root, - - [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() - $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.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') - }) - } - catch { - if ($IsWindows) { - throw "Unable to inspect listener processes for $Root." - } - return @() - } - } - - $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) - } - } - - 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 ', ')") - } - 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 { - 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, - - [Parameter(Mandatory = $true)] - [string] $DotNetInstallDirectory - ) - - $lines = Get-SmartPipeOwnedEnvironment -EnvironmentPath $EnvironmentPath - $owned = @{ - 'ACTIONS_RUNNER_HOOK_JOB_STARTED' = $HookPath - 'DOTNET_INSTALL_DIR' = $DotNetInstallDirectory - } - - 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])") - } - - $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_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}=") { - $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 deleted file mode 100644 index 032485a..0000000 --- a/eng/runner/uninstall-runner.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -[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 - Assert-SmartPipeListenerSafety -Root $runner -FixturePath $ListenerFixturePath - - $environmentPath = Join-Path $runner '.env' - Remove-SmartPipeEnvironment -EnvironmentPath $environmentPath - - $hookDirectory = Join-Path $runner 'hooks' - 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 - 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 deleted file mode 100644 index b1c16bb..0000000 --- a/eng/tests/runner-contract.Tests.ps1 +++ /dev/null @@ -1,373 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$runnerScriptRoot = Join-Path $PSScriptRoot '..\runner' -$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')) - -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, - [string] $WorkingDirectory = '' - ) - - 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 = $exitCode - 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 $jobStartScript -WorkingDirectory $workspace -Arguments @( - '-RunnerRoot', $runnerRoot, - '-WorkspaceRoot', $workspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-AllowTestRoot' - ) - Assert-RunnerEqual -Actual $cleanup.ExitCode -Expected 0 -Message "Job-start cleanup must succeed for a valid checkout. $($cleanup.Output)" - 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.' - 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, - '-WorkspaceRoot', $absentWorkspace, - '-TempRoot', $tempRoot, - '-Repository', 'MrFr3di/SmartPipe-Core', - '-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 - @' -[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 $jobStartScript -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 $jobStartScript -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 $jobStartScript -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 -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 @( - '-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-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 - $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-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 - $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_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-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', - '-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_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-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.' - - $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 dcf05ae..c3920fd 100644 --- a/eng/tests/workflow-contract.Tests.ps1 +++ b/eng/tests/workflow-contract.Tests.ps1 @@ -7,9 +7,3 @@ 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 4411a9d..4e3e65a 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -31,21 +31,22 @@ ) } SHA_REF = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") -SELF_HOSTED_WINDOWS = ["self-hosted", "Windows", "X64", "smartpipe-cleanup-v1"] -SELF_HOSTED_WINDOWS_JSON = '["self-hosted","Windows","X64","smartpipe-cleanup-v1"]' +HOSTED_WINDOWS = "windows-latest" +HOSTED_WINDOWS_JSON = '["windows-latest"]' +CODEQL_ACTION_REF = ( + "github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9" +) +CODEQL_ANALYZE_ACTION_REF = ( + "github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9" +) +DEPENDENCY_REVIEW_ACTION_REF = ( + "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294" +) SAME_REPOSITORY_PR_GUARD = ( "github.event_name != 'pull_request' || " "github.event.pull_request.head.repo.full_name == github.repository" ) PULL_REQUEST_SAME_REPOSITORY_GUARD = "github.event.pull_request.head.repo.full_name == github.repository" -CLEANUP_SAME_REPOSITORY_GUARD = ( - "always() && (github.event_name != 'pull_request' || " - "github.event.pull_request.head.repo.full_name == github.repository)" -) -CLEANUP_PULL_REQUEST_GUARD = ( - "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 == '' && " @@ -59,26 +60,16 @@ ) CI_VALIDATION_RUNNER_INPUT = ( "${{ github.event_name == 'pull_request' && " - "'[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]' || " + "'[\"windows-latest\"]' || " "'[\"ubuntu-latest\"]' }}" ) -CI_WINDOWS_RUNNER = ( - "${{ github.event_name == 'pull_request' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || " - "'windows-latest' }}" -) -NUGET_PACKAGES_PR = ( - "${{ github.event_name == 'pull_request' && " - "format('{0}/.nuget/packages', github.workspace) || '' }}" -) -HOSTING_NAME = "${{ matrix.os == 'self-hosted' && 'Windows' || matrix.os }}" -HOSTING_RUNNER = ( - "${{ matrix.os == 'self-hosted' && " - "fromJSON('[\"self-hosted\",\"Windows\",\"X64\",\"smartpipe-cleanup-v1\"]') || matrix.os }}" -) +CI_WINDOWS_RUNNER = HOSTED_WINDOWS +NUGET_PACKAGES_PATH = "${{ github.workspace }}/.nuget/packages" +HOSTING_NAME = "${{ matrix.os == 'windows-latest' && 'Windows' || matrix.os }}" +HOSTING_RUNNER = "${{ matrix.os }}" HOSTING_MATRIX = ( "${{ fromJSON(github.event_name == 'pull_request' && " - "'{\"os\":[\"self-hosted\"]}' || " + "'{\"os\":[\"windows-latest\"]}' || " "'{\"os\":[\"ubuntu-latest\",\"windows-latest\"]}') }}" ) LYCHEE_URL = ( @@ -99,9 +90,9 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -def require_self_hosted_windows(job: dict, label: str) -> None: - require(job.get("runs-on") == SELF_HOSTED_WINDOWS, - f"{label} must target the self-hosted Windows X64 runner labels.") +def require_hosted_windows(job: dict, label: str) -> None: + require(job.get("runs-on") == HOSTED_WINDOWS, + f"{label} must target hosted Windows (`windows-latest`).") def require_parameterized_runner(job: dict, label: str) -> None: @@ -114,53 +105,66 @@ def require_runner_expression(job: dict, expected: str, label: str) -> None: f"{label} must use the event-aware runner expression.") -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.") - +def assert_codeql_contract(workflow: dict) -> None: + require(workflow.get("name") == "CodeQL", + "CodeQL workflow must retain the official public check name.") + require(workflow.get("permissions") == { + "contents": "read", + "security-events": "write", + }, "CodeQL must request only contents read and security-events write permissions.") jobs = workflow.get("jobs", {}) require(set(jobs) == {"analyze"}, - "Hosted static analysis must define only the analyzer job.") + "CodeQL workflow must define only the analyze job.") job = jobs["analyze"] - require(job.get("name") == "Hosted .NET static analysis", - "Static analysis job must preserve its distinct check name.") + require(job.get("if") == SAME_REPOSITORY_PR_GUARD, + "CodeQL must skip untrusted fork pull requests.") require(job.get("runs-on") == "ubuntu-latest", - "Static analysis must use hosted Linux.") - static_steps = steps(job, "Hosted .NET static analysis") + "CodeQL must use hosted Linux.") + codeql_steps = steps(job, "CodeQL analyze") checkout = next( - step for step in static_steps + step for step in codeql_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") + "CodeQL checkout must disable persisted credentials.") + setup = named_step(codeql_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.") + "CodeQL must use the pinned SDK from global.json.") + require(setup.get("with", {}).get("cache") is True + and setup.get("with", {}).get("cache-dependency-path") == "**/packages.lock.json", + "CodeQL setup-dotnet must cache only the lock-file keyed NuGet packages.") + init = named_step(codeql_steps, "Initialize CodeQL") + require(init.get("uses") == CODEQL_ACTION_REF + and init.get("with", {}).get("languages") == "csharp", + "CodeQL must initialize the official pinned C# action.") + build = named_step(codeql_steps, "Build") + require(build.get("run") == "dotnet build SmartPipe.Core.slnx -c Release", + "CodeQL must build the solution before analysis.") + analyze = named_step(codeql_steps, "Perform CodeQL Analysis") + require(analyze.get("uses") == CODEQL_ANALYZE_ACTION_REF, + "CodeQL must run the official pinned analysis action.") def assert_nuget_isolation_contract(workflow: dict, workflow_name: str) -> None: environment = workflow.get("env") require(isinstance(environment, dict) - and environment.get("NUGET_PACKAGES") == NUGET_PACKAGES_PR, - f"{workflow_name} must isolate pull-request NuGet packages inside GITHUB_WORKSPACE.") + and environment.get("NUGET_PACKAGES") == NUGET_PACKAGES_PATH, + f"{workflow_name} must isolate NuGet packages inside GITHUB_WORKSPACE.") + + +def assert_setup_dotnet_cache_contract(workflow: dict, workflow_name: str) -> None: + setup_steps = [ + step + for job in workflow["jobs"].values() + for step in job.get("steps", []) + if str(step.get("uses", "")).startswith("actions/setup-dotnet") + ] + require(bool(setup_steps), f"{workflow_name} must contain setup-dotnet steps.") + for step in setup_steps: + with_block = step.get("with", {}) + require(with_block.get("cache") is True + and with_block.get("cache-dependency-path") == "**/packages.lock.json", + f"{workflow_name} restore-heavy setup-dotnet must use lock-file keyed caching.") def assert_diagnostic_contract(ci: dict) -> None: @@ -179,7 +183,7 @@ def assert_diagnostic_contract(ci: dict) -> None: 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") + require_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", "")) @@ -244,126 +248,30 @@ def require_ci_normal_job_guard(job: dict, label: str) -> None: f"{label} must retain the same-repository guard and skip only diagnostic dispatches.") -def assert_cleanup_job( - workflow: dict, - workflow_name: str, - expected_needs: list[str], - expected_guard: str, - cleanup_nuget: bool = False, -) -> None: - job = workflow["jobs"].get("cleanup-self-hosted") - require(isinstance(job, dict), - f"{workflow_name} must define cleanup-self-hosted.") - require(job.get("name") == "Cleanup self-hosted workspace", - f"{workflow_name} cleanup must preserve its check name.") - require(job.get("needs") == expected_needs, - f"{workflow_name} cleanup must wait for every workflow job.") - require_self_hosted_windows(job, f"{workflow_name} cleanup") - require(job.get("if") == expected_guard, - f"{workflow_name} cleanup must always run only for trusted repository work.") - cleanup_steps = steps(job, f"{workflow_name} cleanup") - require(len(cleanup_steps) == 1, - f"{workflow_name} cleanup must contain exactly one cleanup step.") - cleanup = named_step(cleanup_steps, "Cleanup generated outputs") - require(cleanup.get("shell") == "pwsh", - f"{workflow_name} cleanup must use PowerShell on Windows.") - script = str(cleanup.get("run", "")) - for token in ( - "$env:GITHUB_WORKSPACE", "[IO.Path]::GetFullPath", "StartsWith", - "[StringComparison]::OrdinalIgnoreCase", "[IO.FileAttributes]::ReparsePoint", - "Join-Path $workspace 'artifacts'", - "Join-Path $workspace 'BenchmarkDotNet.Artifacts'", - "$directory.Name -in 'bin', 'obj'", - "Remove-Item -LiteralPath $fullPath -Recurse -Force", - ): - require(token in script, - f"{workflow_name} cleanup must enforce safe workspace-bound deletion ({token}).") - if cleanup_nuget: - require("Join-Path $workspace '.nuget'" in script, - f"{workflow_name} cleanup must remove its workspace-local NuGet packages.") - require("git clean" not in script.lower(), - f"{workflow_name} cleanup must not use git clean.") - require(re.search(r"Remove-Item\s+-LiteralPath\s+\$workspace(?:\s|$)", script) is None, - f"{workflow_name} cleanup must not delete the workspace root.") - direct_reparse_guard = ( - "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " - "[IO.FileAttributes]::ReparsePoint)" - ) - require(direct_reparse_guard in script, - f"{workflow_name} cleanup must reject direct target reparse points before recursive deletion.") - require(script.index(direct_reparse_guard) < script.index( - "Get-ChildItem -LiteralPath $fullPath -Force -Recurse"), - 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.") +def assert_dependency_review_contract(workflow: dict) -> None: + require(workflow.get("name") == "Dependency Review", + "Dependency Review workflow must retain the official public check name.") + require(workflow.get("permissions") == { + "contents": "read", + "pull-requests": "read", + }, "Dependency Review must request only contents and pull-requests read permissions.") 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(set(jobs) == {"dependency-review"}, + "Dependency Review workflow must define only the dependency-review job.") + job = jobs["dependency-review"] 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") + "Dependency Review must use hosted Linux.") + require("if" not in job, + "Dependency Review must run for public fork pull requests as well as same-repository requests.") + job_steps = steps(job, "Dependency Review") 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.") + "Dependency Review checkout must be pinned and credential-free.") + review = named_step(job_steps, "Dependency review") + require(review.get("uses") == DEPENDENCY_REVIEW_ACTION_REF, + "Dependency Review must run the official pinned public action.") def assert_reusable_windows_shell_contract(reusable_steps: list[dict]) -> None: @@ -933,7 +841,7 @@ def validate(documents: dict[str, dict]) -> None: require_parameterized_runner(concurrency_job, "HealthChecks concurrency") require_same_repository_pr_guard(concurrency_job, "HealthChecks concurrency") require("strategy" not in concurrency_job, - "HealthChecks concurrency must use one self-hosted Windows lane while Linux hosted minutes are unavailable.") + "HealthChecks concurrency must use one hosted runner lane.") concurrency_steps = steps(concurrency_job, "reusable health-checks-concurrency") for step_name in ("Run bounded observation concurrency", "Run concurrent health evaluation"): command = str(named_step(concurrency_steps, step_name).get("run", "")) @@ -969,6 +877,9 @@ def validate(documents: dict[str, dict]) -> None: "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.") + require(upload.get("with", {}).get("retention-days") == + "${{ inputs.artifact-name == 'packages' && 7 || 90 }}", + "Reusable validation must retain generic CI packages for seven days and versioned artifacts for the existing policy.") upload_path = str(upload.get("with", {}).get("path", "")) require("artifacts/packages" in upload_path and "artifacts/consumers/**/result.json" in upload_path @@ -1053,7 +964,6 @@ def validate(documents: dict[str, dict]) -> None: explicit_names.append((str(job["name"]), file_name, job_id)) duplicates = {name for name, _, _ in explicit_names if sum(item[0] == name for item in explicit_names) > 1} - duplicates.discard("Cleanup self-hosted workspace") require(not duplicates, f"Required job/check names must be unique: {sorted(duplicates)}") windows_text = "\n".join(windows_runs) @@ -1064,16 +974,19 @@ def validate(documents: dict[str, dict]) -> None: "Windows lifecycle filter must not use the obsolete " "SmartPipe.Extensions.Tests.Sinks namespace.") - assert_cleanup_job( - ci, - "ci.yml", - ["validation", "hosting-integration", "json-file-windows", "baseline-contract-windows"], - CLEANUP_PULL_REQUEST_GUARD, - cleanup_nuget=True, - ) - assert_repository_security_audit_contract(dependency_review) + for workflow_name, document in documents.items(): + require("cleanup-self-hosted" not in document["jobs"], + f"{workflow_name} must not define the obsolete cleanup-self-hosted job.") + assert_dependency_review_contract(dependency_review) assert_nuget_isolation_contract(ci, "ci.yml") - assert_static_analysis_contract(static_analysis) + assert_nuget_isolation_contract(static_analysis, "codeql.yml") + assert_codeql_contract(static_analysis) + for workflow_name, document in ( + ("ci.yml", ci), + ("codeql.yml", static_analysis), + ("reusable-release-validation.yml", reusable), + ): + assert_setup_dotnet_cache_contract(document, workflow_name) 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,55 +1172,57 @@ def _add_consumer_logs_to_upload(documents: dict[str, dict]) -> None: upload["with"]["path"] += "\nartifacts/consumers/**/logs/**" -def _use_hosted_runner_for_required_lanes(documents: dict[str, dict]) -> None: - lanes = ( - ("ci.yml", "hosting-integration"), - ("ci.yml", "json-file-windows"), - ("ci.yml", "baseline-contract-windows"), - ("reusable-release-validation.yml", "build-test-pack"), - ("reusable-release-validation.yml", "health-checks-concurrency"), - ) - 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 - - -def _make_hosting_always_self_hosted(documents: dict[str, dict]) -> None: +def _make_hosting_matrix_hosted_only(documents: dict[str, dict]) -> None: job = documents["ci.yml"]["jobs"]["hosting-integration"] job["strategy"]["matrix"] = ( - "${{ fromJSON('{\"os\":[\"self-hosted\"]}') }}" + "${{ fromJSON('{\"os\":[\"ubuntu-latest\"]}') }}" ) def _make_hosting_static_runner(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["hosting-integration"]["runs-on"] = SELF_HOSTED_WINDOWS + documents["ci.yml"]["jobs"]["hosting-integration"]["runs-on"] = HOSTED_WINDOWS -def _make_ci_json_always_self_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["json-file-windows"]["runs-on"] = SELF_HOSTED_WINDOWS +def _make_codeql_substitute_name(documents: dict[str, dict]) -> None: + documents["codeql.yml"]["name"] = "Private static analysis" -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_non_official_action(documents: dict[str, dict]) -> None: + init = named_step(documents["codeql.yml"]["jobs"]["analyze"]["steps"], "Initialize CodeQL") + init["uses"] = "github/codeql-action/init@0000000000000000000000000000000000000000" -def _make_static_analysis_always_self_hosted(documents: dict[str, dict]) -> None: - documents["codeql.yml"]["jobs"]["analyze"]["runs-on"] = SELF_HOSTED_WINDOWS +def _make_dependency_review_non_official_action(documents: dict[str, dict]) -> None: + review = named_step( + documents["dependency-review.yml"]["jobs"]["dependency-review"]["steps"], + "Dependency review", + ) + review["uses"] = "actions/dependency-review-action@0000000000000000000000000000000000000000" def _remove_nuget_isolation(documents: dict[str, dict], workflow_name: str) -> None: documents[workflow_name]["env"].pop("NUGET_PACKAGES", None) -def _make_cleanup_non_pr_capable(documents: dict[str, dict], workflow_name: str) -> None: - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["if"] = CLEANUP_SAME_REPOSITORY_GUARD +def _remove_setup_dotnet_cache(documents: dict[str, dict], workflow_name: str) -> None: + for job in documents[workflow_name]["jobs"].values(): + for step in job.get("steps", []): + if str(step.get("uses", "")).startswith("actions/setup-dotnet"): + step["with"].pop("cache", None) + step["with"].pop("cache-dependency-path", None) + return + + +def _change_artifact_retention(documents: dict[str, dict]) -> None: + upload = named_step( + documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], + "Upload immutable packages and reports", + ) + upload["with"]["retention-days"] = 7 + + +def _add_ci_cleanup_job(documents: dict[str, dict]) -> None: + documents["ci.yml"]["jobs"]["cleanup-self-hosted"] = {} def _remove_ci_runner_override(documents: dict[str, dict]) -> None: @@ -1319,7 +1234,7 @@ def _remove_diagnostic_input(documents: dict[str, dict]) -> None: def _make_diagnostic_hosted(documents: dict[str, dict]) -> None: - documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "windows-latest" + documents["ci.yml"]["jobs"]["diagnostic-consumer"]["runs-on"] = "ubuntu-latest" def _make_ci_normal_job_diagnostic_capable(documents: dict[str, dict]) -> None: @@ -1373,7 +1288,7 @@ def _change_runner_default(documents: dict[str, dict]) -> None: def _override_publish_runner(documents: dict[str, dict]) -> None: documents["publish-nuget.yml"]["jobs"]["validation"].setdefault("with", {})[ "runner-labels" - ] = SELF_HOSTED_WINDOWS_JSON + ] = HOSTED_WINDOWS_JSON def _remove_leaf_exit_guard(documents: dict[str, dict]) -> None: @@ -1444,44 +1359,6 @@ def _remove_reusable_pr_guard(documents: dict[str, dict]) -> None: documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"].pop("if", None) -def _remove_ci_cleanup_job(documents: dict[str, dict]) -> None: - del documents["ci.yml"]["jobs"]["cleanup-self-hosted"] - - -def _make_ci_cleanup_delete_workspace_root(documents: dict[str, dict]) -> None: - cleanup = named_step( - documents["ci.yml"]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - cleanup["run"] = str(cleanup["run"]) + "\nRemove-Item -LiteralPath $workspace -Recurse -Force" - - -def _remove_cleanup_direct_target_guard(documents: dict[str, dict], workflow_name: str) -> None: - cleanup = named_step( - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - direct_reparse_guard = ( - "if ((Get-Item -LiteralPath $fullPath -Force).Attributes -band " - "[IO.FileAttributes]::ReparsePoint)" - ) - cleanup["run"] = "\n".join( - line for line in str(cleanup.get("run", "")).splitlines() - if direct_reparse_guard not in line - ) - - -def _remove_cleanup_nuget_target(documents: dict[str, dict], workflow_name: str) -> None: - cleanup = named_step( - documents[workflow_name]["jobs"]["cleanup-self-hosted"]["steps"], - "Cleanup generated outputs", - ) - cleanup["run"] = "\n".join( - line for line in str(cleanup.get("run", "")).splitlines() - if "Join-Path $workspace '.nuget'" not in line - ) - - def _restore_lychee_action(documents: dict[str, dict]) -> None: cleanup = named_step( documents["reusable-release-validation.yml"]["jobs"]["build-test-pack"]["steps"], @@ -1555,7 +1432,7 @@ def main() -> int: assert_mutation_rejected( documents, _make_diagnostic_hosted, - "must target the self-hosted Windows X64 runner labels", + "must target hosted Windows", ) assert_mutation_rejected( documents, @@ -1659,7 +1536,7 @@ def main() -> int: assert_mutation_rejected( documents, _restore_floating_sdk_selection, - "global.json as the SDK source", + "global.json", ) assert_mutation_rejected(documents, _remove_release_branch, "release/2.2.0") assert_mutation_rejected( @@ -1718,49 +1595,53 @@ def main() -> int: ) assert_mutation_rejected( documents, - _use_hosted_runner_for_required_lanes, - "runner-labels workflow input", + _make_hosting_matrix_hosted_only, + "original hosted non-PR matrix", ) assert_mutation_rejected( documents, - _make_repository_security_audit_self_hosted, - "must use hosted Linux", + _make_hosting_static_runner, + "event-aware runner expression", ) assert_mutation_rejected( documents, - _make_ci_validation_always_self_hosted, - "exact reusable workflow caller", + lambda docs: docs["ci.yml"]["jobs"]["json-file-windows"].__setitem__( + "runs-on", "ubuntu-latest"), + "Windows JSON lane must use the event-aware runner expression", ) assert_mutation_rejected( documents, - _make_hosting_always_self_hosted, - "original hosted non-PR matrix", + lambda docs: docs["ci.yml"]["jobs"]["baseline-contract-windows"].__setitem__( + "runs-on", "ubuntu-latest"), + "Windows baseline contract lane must use the event-aware runner expression", ) assert_mutation_rejected( documents, - _make_hosting_static_runner, - "event-aware runner expression", + _make_codeql_substitute_name, + "official public check name", ) assert_mutation_rejected( documents, - _make_ci_json_always_self_hosted, - "event-aware runner expression", + _make_codeql_non_official_action, + "official pinned C# action", ) assert_mutation_rejected( documents, - _make_ci_baseline_always_self_hosted, - "event-aware runner expression", + _make_dependency_review_non_official_action, + "official pinned public action", ) - assert_mutation_rejected( - documents, - _make_static_analysis_always_self_hosted, - "must not retain self-hosted", - ) - for workflow_name in ("ci.yml", "reusable-release-validation.yml"): + for workflow_name in ("ci.yml", "reusable-release-validation.yml", "codeql.yml"): assert_mutation_rejected( documents, lambda docs, name=workflow_name: _remove_nuget_isolation(docs, name), - f"{workflow_name} must isolate pull-request NuGet packages", + f"{workflow_name} must isolate NuGet packages", + ) + assert_mutation_rejected( + documents, + lambda docs, name=workflow_name: _remove_setup_dotnet_cache(docs, name), + "cache only the lock-file keyed" + if workflow_name == "codeql.yml" + else f"{workflow_name} restore-heavy setup-dotnet", ) assert_mutation_rejected( documents, @@ -1777,6 +1658,11 @@ def main() -> int: _override_publish_runner, "Publish validation must use reusable hosted Linux runner default", ) + assert_mutation_rejected( + documents, + _change_artifact_retention, + "retain generic CI packages for seven days", + ) assert_mutation_rejected( documents, _remove_leaf_exit_guard, @@ -1823,32 +1709,9 @@ def main() -> int: ) assert_mutation_rejected( documents, - _remove_ci_cleanup_job, - "must define cleanup-self-hosted", + _add_ci_cleanup_job, + "must not define the obsolete cleanup-self-hosted job", ) - for workflow_name in ("ci.yml",): - assert_mutation_rejected( - documents, - lambda docs, name=workflow_name: _make_cleanup_non_pr_capable(docs, name), - f"{workflow_name} cleanup must always run only for trusted repository work", - ) - assert_mutation_rejected( - documents, - _make_ci_cleanup_delete_workspace_root, - "must not delete the workspace root", - ) - 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",): - assert_mutation_rejected( - documents, - lambda docs, name=workflow_name: _remove_cleanup_nuget_target(docs, name), - f"{workflow_name} cleanup must remove its workspace-local NuGet packages", - ) assert_mutation_rejected( documents, _restore_lychee_action, diff --git a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs index 8edf644..5076327 100644 --- a/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs +++ b/tests/SmartPipe.RepositoryChecks.Tests/Commands/BaselineOrchestrationTests.cs @@ -121,7 +121,7 @@ public async Task Capture_AcceptsAndPersistsCurrentWorkflowNames() scenario.ManifestPath, TestContext.Current.CancellationToken))!.AsObject(); Assert.Equal( - ["CI", "Hosted .NET static analysis", "Repository security audit"], + ["CI", "CodeQL", "Dependency Review"], root["repository"]!["requiredWorkflows"]!.AsArray() .Select(workflow => workflow!["name"]!.GetValue()) .Order(StringComparer.Ordinal)); @@ -284,15 +284,15 @@ public async Task Capture_RejectsDuplicateSuccessfulWorkflowEvidenceAsAmbiguous( } [Fact] - public async Task Capture_RejectsHistoricalSecurityWorkflowName() + public async Task Capture_RejectsSubstituteSecurityWorkflowName() { using var scenario = new BaselineScenario(); - scenario.WriteWorkflowEvidence("historical-security-name"); + scenario.WriteWorkflowEvidence("substitute-security-name"); var exception = await Assert.ThrowsAsync( () => scenario.CaptureAsync(TestContext.Current.CancellationToken)); - Assert.Contains("Repository security audit", exception.Message, StringComparison.Ordinal); + Assert.Contains("Dependency Review", exception.Message, StringComparison.Ordinal); } [Fact] @@ -338,34 +338,17 @@ public async Task ManifestMutation_Fails() } [Fact] - public async Task HistoricalManifestWorkflowNamesReachNormalIntegrityDiagnostics() + public async Task CanonicalManifestWorkflowNamesReachNormalIntegrityDiagnostics() { 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); + scenario.ManifestPath, manifest, TestContext.Current.CancellationToken); await File.WriteAllBytesAsync( Path.Combine(scenario.BaselinePath, "baseline-report.md"), - BaselineReport.Create(historicalManifest), TestContext.Current.CancellationToken); + BaselineReport.Create(manifest), TestContext.Current.CancellationToken); await File.AppendAllTextAsync(scenario.PublicApiPath, "\nHistorical.Api", TestContext.Current.CancellationToken); var result = await scenario.VerifyAsync(); @@ -375,7 +358,7 @@ await File.WriteAllBytesAsync( } [Theory] - [InlineData("CodeQL")] + [InlineData("Dependency Review")] [InlineData("Unexpected workflow")] public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string replacementName) { @@ -384,7 +367,7 @@ public async Task NonCompleteManifestWorkflowNamesFailSchemaValidation(string re 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; + workflows.Single(workflow => workflow!["name"]!.GetValue() == "CodeQL")!["name"] = replacementName; await File.WriteAllTextAsync( scenario.ManifestPath, root.ToJsonString(), TestContext.Current.CancellationToken); @@ -793,9 +776,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 securityWorkflowName = mutation == "substitute-security-name" + ? "Repository security audit" + : "Dependency Review"; var extra = mutation switch { "extra-pending" => $$""" @@ -815,7 +798,7 @@ 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":"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":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":"{{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}} ] """; From 4586d81f1c4be3ae6bde0cedf42e76f1db9894c7 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Thu, 27 Aug 2026 21:48:01 +0500 Subject: [PATCH 2/2] fix(ci): keep hosted restores source-stable --- .github/workflows/ci.yml | 6 ++--- .../workflows/reusable-release-validation.yml | 4 ++-- eng/tests/workflow_contract_tests.py | 22 ++++++++++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befa3cf..0bf9d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore Hosting integration tests - run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode + run: dotnet restore tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build Hosting integration tests run: dotnet build tests/SmartPipe.Extensions.Hosting.Tests/SmartPipe.Extensions.Hosting.Tests.csproj --configuration Release --no-restore -warnaserror @@ -86,7 +86,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build JSON test project run: dotnet build tests/SmartPipe.Extensions.Json.Tests/SmartPipe.Extensions.Json.Tests.csproj --configuration Release --no-restore -warnaserror @@ -200,7 +200,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build run: dotnet build SmartPipe.Core.slnx --configuration Release --no-restore -warnaserror diff --git a/.github/workflows/reusable-release-validation.yml b/.github/workflows/reusable-release-validation.yml index 2d19453..2e369a3 100644 --- a/.github/workflows/reusable-release-validation.yml +++ b/.github/workflows/reusable-release-validation.yml @@ -44,7 +44,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Test release version validation shell: pwsh @@ -291,7 +291,7 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: Restore locked - run: dotnet restore SmartPipe.Core.slnx --locked-mode + run: dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true - name: Build concurrency projects shell: pwsh diff --git a/eng/tests/workflow_contract_tests.py b/eng/tests/workflow_contract_tests.py index 4e3e65a..7ba0634 100644 --- a/eng/tests/workflow_contract_tests.py +++ b/eng/tests/workflow_contract_tests.py @@ -167,6 +167,16 @@ def assert_setup_dotnet_cache_contract(workflow: dict, workflow_name: str) -> No f"{workflow_name} restore-heavy setup-dotnet must use lock-file keyed caching.") +def assert_hosted_restore_source_contract(documents: dict[str, dict]) -> None: + for workflow_name in ("ci.yml", "reusable-release-validation.yml"): + workflow = documents[workflow_name] + for job_name, job in workflow["jobs"].items(): + for command in runs(job.get("steps", [])): + if "dotnet restore " in command: + require("-p:DisableImplicitLibraryPacksFolder=true" in command, + f"{workflow_name}:{job_name} hosted restore must disable the SDK library-packs source.") + + def assert_diagnostic_contract(ci: dict) -> None: dispatch = ci.get("on", {}).get("workflow_dispatch", {}) inputs = dispatch.get("inputs", {}) if isinstance(dispatch, dict) else {} @@ -202,7 +212,8 @@ def assert_diagnostic_contract(ci: dict) -> None: 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", + require(str(restore.get("run", "")).strip() == + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", "Diagnostic consumer must perform one locked solution restore.") build = named_step(diagnostic_steps, "Build") require("--no-restore" in str(build.get("run", "")) @@ -716,6 +727,7 @@ def validate(documents: dict[str, dict]) -> None: for workflow_name, expected in expected_triggers.items(): require(documents[workflow_name].get("on") == expected, f"{workflow_name} trigger contract changed.") + assert_hosted_restore_source_contract(documents) workflow_call = reusable.get("on", {}).get("workflow_call") require(isinstance(workflow_call, dict), "Reusable validation must declare on.workflow_call.") @@ -743,7 +755,9 @@ def validate(documents: dict[str, dict]) -> None: require(any("ruamel.yaml==0.18.16" in command for command in reusable_runs), "Reusable validation must install the pinned YAML 1.2 parser.") restores = [command for command in reusable_runs if "dotnet restore SmartPipe.Core.slnx" in command] - require(restores == ["dotnet restore SmartPipe.Core.slnx --locked-mode"], + require(restores == [ + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", + ], "Reusable validation must perform exactly one locked-mode solution restore.") build_step = named_step(reusable_steps, "Build") repository_test_step = named_step(reusable_steps, "Repository baseline contract tests") @@ -926,7 +940,9 @@ def validate(documents: dict[str, dict]) -> None: 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] - require(windows_restores == ["dotnet restore SmartPipe.Core.slnx --locked-mode"], + require(windows_restores == [ + "dotnet restore SmartPipe.Core.slnx --locked-mode -p:DisableImplicitLibraryPacksFolder=true", + ], "Windows JSON lane must perform exactly one locked-mode solution restore.") require(not any("Category=Stress" in command for command in windows_runs), "Windows JSON lane must not execute the stress suite.")