Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3010287
ci: bump the actions group with 2 updates
dependabot[bot] Oct 13, 2026
718f282
deps: Bump the patch-and-minor group with 9 updates
dependabot[bot] Oct 13, 2026
9d5cc34
Use sql2025 in the package README example
erikdarlingdata Oct 14, 2026
32a72a8
Merge pull request #649 from erikdarlingdata/fix/646-readme-sql2025
erikdarlingdata Oct 14, 2026
61040e9
Suppress individual findings by query hash in .planview.json (#651)
erikdarlingdata Oct 14, 2026
e0a65f5
Fix CI build, config error handling, suppressed counts and docs (#651)
erikdarlingdata Oct 14, 2026
8b78ee3
Report an unreadable .planview.json as one line, not a crash (#651)
erikdarlingdata Oct 14, 2026
b541dcf
Merge pull request #644 from erikdarlingdata/dependabot/nuget/dev/pat…
erikdarlingdata Oct 14, 2026
d9f2d67
Merge pull request #643 from erikdarlingdata/dependabot/github_action…
erikdarlingdata Oct 14, 2026
347d863
Merge pull request #652 from erikdarlingdata/fix/651-suppressions
erikdarlingdata Oct 14, 2026
92bd902
Pin vulnerable transitive packages and audit them in CI (#650)
erikdarlingdata Oct 14, 2026
5dfe9b7
Exclude MessagePack runtime assets in SSMS, fix workflow steps and a …
erikdarlingdata Oct 14, 2026
e49b942
Apply round-1 review: gate NU1904, harden dependency submission, fix …
erikdarlingdata Oct 14, 2026
b351f19
Merge pull request #654 from erikdarlingdata/fix/650-transitive-pins
erikdarlingdata Oct 14, 2026
a98c6c7
Submit the dependency snapshot without a third-party binary holding t…
erikdarlingdata Oct 15, 2026
74d552c
Apply round-1 security review to the dependency snapshot workflow (#655)
erikdarlingdata Oct 15, 2026
7621e83
Restore line continuations in the submit step (#655)
erikdarlingdata Oct 15, 2026
4f4f59f
Merge pull request #656 from erikdarlingdata/fix/655-dependency-snapshot
erikdarlingdata Oct 15, 2026
0d058da
Run the SSMS restore step under pwsh with per-command exit checks (#655)
erikdarlingdata Oct 15, 2026
4e63d5d
Merge pull request #657 from erikdarlingdata/fix/655-ssms-restore-pwsh
erikdarlingdata Oct 15, 2026
9b1315b
ci: bump the actions group with 2 updates
dependabot[bot] Oct 19, 2026
563da59
Self CPU: subtract whole batch zones, and sum threads for an operator…
erikdarlingdata Oct 19, 2026
060c304
Update two comments for the analyzer-owned own times
erikdarlingdata Oct 19, 2026
da1f33e
Fall back to the estimated mode in the analyzer's own-time batch checks
erikdarlingdata Oct 19, 2026
3761fcb
Keep a batch node's total CPU when it has no per-thread rows
erikdarlingdata Oct 19, 2026
ec6573c
Merge pull request #662 from erikdarlingdata/fix/self-cpu-batch-zones
erikdarlingdata Oct 19, 2026
b59e074
get_expensive_operators ranks by own time, not cumulative elapsed (#658)
erikdarlingdata Oct 19, 2026
c7f0f08
Merge pull request #663 from erikdarlingdata/fix/658-expensive-operat…
erikdarlingdata Oct 19, 2026
6ed2ec7
deps: apply the 13 patch-and-minor bumps from #660; ignore SelectionC…
erikdarlingdata Oct 19, 2026
5a8679b
Merge pull request #664 from erikdarlingdata/deps/660-patch-and-minor
erikdarlingdata Oct 19, 2026
d26b02f
Bump version to 1.31.0
erikdarlingdata Oct 20, 2026
825fa75
Merge pull request #665 from erikdarlingdata/release/v1.31.0
erikdarlingdata Oct 20, 2026
d64f34f
Merge pull request #659 from erikdarlingdata/dependabot/github_action…
erikdarlingdata Oct 20, 2026
d3d8225
Merge main into dev for the v1.31.0 release
erikdarlingdata Oct 20, 2026
9ecbb8b
Merge pull request #667 from erikdarlingdata/sync/main-into-dev-v1.31.0
erikdarlingdata Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions .github/scripts/New-DependencySnapshot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Builds a GitHub dependency snapshot from the obj/project.assets.json files NuGet wrote.

.DESCRIPTION
Reads every obj/project.assets.json under the repository and writes the JSON body for
POST /repos/{owner}/{repo}/dependency-graph/snapshots. No third-party tool is involved: the
assets file already holds the exact package versions NuGet resolved, transitive packages
included, and which of them the project references directly.

The resolved map is keyed by package URL and dependencies list package URLs, as in the
snapshots GitHub accepted from the component-detection action. Packages in tests/ projects
get scope 'development'; every other project's packages get 'runtime'. The assets file does
not mark build-time packages in product projects (ILLink, the WASM DevServer,
VSSDK.BuildTools), so they are listed as 'runtime' too. That only affects alert triage.

One manifest per project per target framework. The manifest is named after the project file
(relative to the repository root, forward slashes), with the target framework appended in
parentheses only when a project has more than one target.

Fails when it finds no assets file or resolves no package at all, so a broken restore cannot
replace the dependency graph with an empty snapshot.

.PARAMETER Sha
Commit SHA the snapshot describes (github.sha).

.PARAMETER Ref
Fully qualified ref, e.g. refs/heads/main (github.ref).

.PARAMETER RunId
Workflow run id (github.run_id).

.PARAMETER Correlator
Stable string that makes a new snapshot replace the previous one from the same source.

.PARAMETER OutputPath
Where to write the snapshot JSON.

.PARAMETER RepoRoot
Repository root. Defaults to two levels above this script.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Sha,
[Parameter(Mandatory)][string]$Ref,
[Parameter(Mandatory)][string]$RunId,
[Parameter(Mandatory)][string]$Correlator,
[string]$OutputPath = 'snapshot.json',
[string]$RepoRoot = (Join-Path $PSScriptRoot '..\..')
)

$ErrorActionPreference = 'Stop'

$root = (Resolve-Path -LiteralPath $RepoRoot).Path.TrimEnd('\', '/')
$rootPrefix = $root.Replace('\', '/') + '/'

function Get-RelativePath([string]$fullPath) {
$normalized = $fullPath.Replace('\', '/')
if ($normalized.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return $normalized.Substring($rootPrefix.Length)
}
return $normalized
}

# Assets files under .claude/, tmp-*/ and bin/ are scratch copies, not part of the product.
$assetsFiles = Get-ChildItem -LiteralPath $root -Recurse -Filter 'project.assets.json' -File |
Where-Object { $_.Directory.Name -eq 'obj' } |
Where-Object {
$rel = Get-RelativePath $_.FullName
$rel -notmatch '(^|/)(\.claude|bin|tmp-[^/]*)/'
} |
Sort-Object FullName

if (-not $assetsFiles) {
throw "No obj/project.assets.json found under $root. Did the restore steps run?"
}

# package_url values: percent-encode the name and version (a version can carry '+' build metadata).
function Get-PackageUrl([string]$name, [string]$version) {
'pkg:nuget/{0}@{1}' -f [uri]::EscapeDataString($name), [uri]::EscapeDataString($version)
}

# Split a "Name/Version" key at the last slash.
function Split-LibraryKey([string]$key) {
$i = $key.LastIndexOf('/')
if ($i -lt 1) { return $null }
[pscustomobject]@{ Name = $key.Substring(0, $i); Version = $key.Substring($i + 1) }
}

$manifests = [ordered]@{}
$totalResolved = 0

foreach ($file in $assetsFiles) {
$assets = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json

# NuGet writes the assets file even when the restore failed, with the packages it could
# resolve and an Error entry in logs. A partial graph must not replace the full one.
$restoreErrors = @($assets.logs | Where-Object { $_.level -eq 'Error' })
if ($restoreErrors.Count -gt 0) {
throw "$($file.FullName): restore logged $($restoreErrors.Count) error(s), first $($restoreErrors[0].code). The snapshot would be incomplete."
}

$projectPath = $assets.project.restore.projectPath
if ([string]::IsNullOrWhiteSpace($projectPath)) {
Write-Warning "Skipping $($file.FullName): no project.restore.projectPath."
continue
}
$projectFile = Get-RelativePath $projectPath
# Packages in tests/ projects are development; everything else is reported as runtime,
# build-only tools in product projects included (the assets file does not mark them).
$scope = $(if ($projectFile -like 'tests/*') { 'development' } else { 'runtime' })

# The direct references: names listed for the target framework, plus the framework-less group.
$directByFramework = @{}
foreach ($group in $assets.projectFileDependencyGroups.PSObject.Properties) {
$names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($line in @($group.Value)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
[void]$names.Add(($line.Trim() -split '\s+', 2)[0])
}
$directByFramework[$group.Name] = $names
}

# A restore with runtime identifiers adds "tfm/rid" targets that repeat the plain "tfm" target's
# packages; keep one manifest per framework and drop the runtime-specific copies.
$allTargets = @($assets.targets.PSObject.Properties)
$plainNames = @($allTargets | Where-Object { $_.Name -notlike '*/*' } | ForEach-Object { $_.Name })
$targets = @($allTargets | Where-Object { $_.Name -notlike '*/*' -or ($plainNames -notcontains ($_.Name -split '/', 2)[0]) })
foreach ($target in $targets) {
# A runtime-specific target is keyed "tfm/rid"; its direct references sit under "tfm".
$framework = ($target.Name -split '/', 2)[0]
$direct = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($key in @($framework, $target.Name, '')) {
if ($directByFramework.ContainsKey($key)) { $direct.UnionWith($directByFramework[$key]) }
}

# Pass 1: every package NuGet resolved for this target.
$packages = [ordered]@{}
foreach ($entry in $target.Value.PSObject.Properties) {
if ($entry.Value.type -ne 'package') { continue }
$id = Split-LibraryKey $entry.Name
if ($null -eq $id) { continue }
$packages[$id.Name] = [pscustomobject]@{ Version = $id.Version; Entry = $entry.Value }
}

# Pass 2: build the resolved map, keyed by package URL (the shape GitHub accepted from the
# previous action). Dependencies are package URLs of packages in this same map.
$purlByName = @{}
foreach ($name in $packages.Keys) { $purlByName[$name] = Get-PackageUrl $name $packages[$name].Version }

$resolved = [ordered]@{}
foreach ($name in $packages.Keys) {
$pkg = $packages[$name]
$purl = $purlByName[$name]
$dependsOn = [System.Collections.Generic.List[string]]::new()
if ($pkg.Entry.PSObject.Properties['dependencies']) {
foreach ($dep in $pkg.Entry.dependencies.PSObject.Properties) {
# $purlByName is case-insensitive, like NuGet package ids.
if ($purlByName.ContainsKey($dep.Name) -and $purlByName[$dep.Name] -ne $purl) {
if (-not $dependsOn.Contains($purlByName[$dep.Name])) { $dependsOn.Add($purlByName[$dep.Name]) }
}
}
}
$resolved[$purl] = [ordered]@{
package_url = $purl
relationship = $(if ($direct.Contains($name)) { 'direct' } else { 'indirect' })
scope = $scope
dependencies = [string[]]$dependsOn.ToArray()
}
}

if ($resolved.Count -eq 0) { continue }

$manifestName = $projectFile
if ($targets.Count -gt 1) { $manifestName = "$projectFile ($($target.Name))" }

$manifests[$manifestName] = [ordered]@{
name = $manifestName
file = [ordered]@{ source_location = $projectFile }
resolved = $resolved
}
$totalResolved += $resolved.Count
Write-Host ("{0}: {1} packages" -f $manifestName, $resolved.Count)
}
}

if ($manifests.Count -eq 0 -or $totalResolved -eq 0) {
throw "Found $($assetsFiles.Count) assets file(s) but no resolved packages; refusing to write an empty snapshot."
}

$snapshot = [ordered]@{
version = 0
sha = $Sha
ref = $Ref
job = [ordered]@{ correlator = $Correlator; id = $RunId }
detector = [ordered]@{
name = 'performancestudio-assets-snapshot'
version = '1.0.0'
url = 'https://github.com/erikdarlingdata/PerformanceStudio'
}
scanned = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
manifests = $manifests
}

$json = $snapshot | ConvertTo-Json -Depth 10
[System.IO.File]::WriteAllText([System.IO.Path]::GetFullPath($OutputPath, (Get-Location).Path), $json, [System.Text.UTF8Encoding]::new($false))

Write-Host ("Wrote {0}: {1} manifests, {2} resolved packages from {3} assets files." -f $OutputPath, $manifests.Count, $totalResolved, $assetsFiles.Count)
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
- 'src/PlanViewer.Core/**'
- 'src/PlanViewer.Web/**'
- 'server/PlanShare/**'
- 'Directory.Build.props'
- 'src/Directory.Build.props'
- 'Directory.Packages.props'
- 'tests/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
with:
fetch-depth: 1

- uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1.0.248
- uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1.0.252
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
with:
fetch-depth: 1

- uses: anthropics/claude-code-action@1d6de8cb0c237e7c15e9e1bdf973826ebae490cc # v1.0.248
- uses: anthropics/claude-code-action@f27be6b65a4652f08a2ba3cff79114829b588967 # v1.0.252
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: |
Expand Down
138 changes: 138 additions & 0 deletions .github/workflows/dependency-submission.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: Dependency submission

# Dependabot's dependency graph cannot read versions from Directory.Packages.props (central
# package management) and no project has a packages.lock.json, so the graph lists direct
# packages with no version and no transitive packages at all. That means no Dependabot
# alerts for a vulnerable transitive package (issue #650). This workflow restores the projects
# and submits what NuGet actually resolved, transitive packages included.
#
# Two jobs, so the token that can write to the repository never sits next to build-time code:
# detect contents: read. Restores the projects (package and MSBuild code runs here) and turns
# the obj/project.assets.json files into a snapshot with
# .github/scripts/New-DependencySnapshot.ps1.
# submit contents: write. Checks out nothing and runs no repository code. It only downloads the
# snapshot artifact and posts it with the gh CLI that ships on the runner (issue #655).
#
# Dependabot alerts read the default branch (main), so transitive alerts start after the next
# dev-to-main release merge. Snapshots from dev feed no alerts; the dev trigger exists to
# exercise this workflow before a release merge.

on:
push:
branches: [main, dev]
workflow_dispatch:

permissions: {}

# A newer push cancels an older run still in progress, so the latest commit's graph is the last
# one submitted and an older sha cannot overwrite it.
concurrency:
group: dependency-submission-${{ github.ref }}
cancel-in-progress: true

jobs:
detect:
# Windows, so the old-format SSMS add-in project can be restored with MSBuild too.
runs-on: windows-latest
permissions:
contents: read

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup .NET 10.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x

# ci.yml does the same before restoring the solution (Blazor WASM project).
- name: Install WASM workload
run: dotnet workload install wasm-tools

# The snapshot script reads project.assets.json, which only exists after a restore. Every
# restore here passes -p:NuGetAudit=false: with the audit gate on, a new high advisory fails
# the restore, the snapshot is never submitted, and the graph goes stale exactly when an
# alert is needed. Resolution is unchanged, and GitHub matches advisories on the submitted graph.
- name: Restore solution
run: dotnet restore PlanViewer.sln -p:NuGetAudit=false

# server/PlanShare is not in PlanViewer.sln and is the internet-facing component.
- name: Restore PlanShare
run: dotnet restore server/PlanShare/PlanShare.csproj -p:NuGetAudit=false

- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0

# pwsh, because the msbuild folder that setup-msbuild adds to PATH reaches pwsh steps but not
# bash steps on the Windows runner (run 38660830104 failed with "msbuild: command not found").
# pwsh only reports the last command's exit code, so each command checks its own.
- name: Restore SSMS projects
shell: pwsh
run: |
dotnet restore src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj -p:NuGetAudit=false
if ($LASTEXITCODE) { exit $LASTEXITCODE }
dotnet restore tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj -p:NuGetAudit=false
if ($LASTEXITCODE) { exit $LASTEXITCODE }
msbuild src/PlanViewer.Ssms/PlanViewer.Ssms.csproj -t:Restore -p:Configuration=Release -p:NuGetAudit=false
if ($LASTEXITCODE) { exit $LASTEXITCODE }

# Fails when there is no assets file or no resolved package, so a broken restore cannot
# replace the graph with an empty snapshot, and when an assets file records a restore error.
# GitHub keeps the latest snapshot per correlator plus detector name. The correlator stays
# "submit" so it is stable for this workflow, but the detector name differs from the old
# action's, so this snapshot does not replace that one: the old action's snapshot exists only
# on dev and stays beside this one (main never received one).
# Values reach the script through env vars, never spliced into the script text.
- name: Build dependency snapshot
shell: pwsh
env:
SNAPSHOT_SHA: ${{ github.sha }}
SNAPSHOT_REF: ${{ github.ref }}
SNAPSHOT_RUN_ID: ${{ github.run_id }}
run: ./.github/scripts/New-DependencySnapshot.ps1 -Sha $env:SNAPSHOT_SHA -Ref $env:SNAPSHOT_REF -RunId $env:SNAPSHOT_RUN_ID -Correlator 'submit' -OutputPath snapshot.json

- name: Upload snapshot
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dependency-snapshot
path: snapshot.json
if-no-files-found: error
retention-days: 7

submit:
needs: detect
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Download snapshot
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: dependency-snapshot

- name: Submit dependency snapshot
env:
GH_TOKEN: ${{ github.token }}
run: |
# sha, ref and run id come from this run, not from the artifact.
jq --arg sha "$GITHUB_SHA" --arg ref "$GITHUB_REF" --arg id "$GITHUB_RUN_ID" \
'.sha = $sha | .ref = $ref | .job.id = $id' snapshot.json > body.json
# On an HTTP error gh writes the error body to stdout, so print it.
if ! gh api --method POST -H "Accept: application/vnd.github+json" \
"repos/$GITHUB_REPOSITORY/dependency-graph/snapshots" \
--input body.json > response.json; then
cat response.json
exit 1
fi
cat response.json
echo
result=$(jq -r '.result // empty' response.json)
echo "result: $result"
echo "message: $(jq -r '.message // empty' response.json)"
if [ "$result" != "SUCCESS" ] && [ "$result" != "ACCEPTED" ]; then
echo "unexpected result: '$result'"
exit 1
fi
1 change: 1 addition & 0 deletions .github/workflows/deploy-planshare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches: [main]
paths:
- 'server/PlanShare/**'
- 'Directory.Build.props'
- 'Directory.Packages.props'
- '.github/workflows/deploy-planshare.yml'
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
paths:
- 'src/PlanViewer.Core/**'
- 'src/PlanViewer.Web/**'
- 'Directory.Build.props'
- 'src/Directory.Build.props'
- 'Directory.Packages.props'
- '.github/workflows/deploy-web.yml'
Expand Down
Loading
Loading