Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d46ae2d
docs(911): promote dependabot fan-out and CI-failing NuGet upgrade bug
drmoisan Sep 19, 2026
ea99d66
docs(911): correct the diagnosis and add spec, research and runbook
drmoisan Sep 19, 2026
32594c3
docs(911): add the atomic plan and the cold-restore failing control
drmoisan Sep 19, 2026
d43a0b2
docs(911): plan revision 2 resolving eight blocking preflight defects
drmoisan Sep 19, 2026
0f714df
docs(911): plan revisions 4 and 5 - analyzer folder preservation and …
drmoisan Sep 19, 2026
bf9a6d2
docs(911): amend spec AC12 to the analyzer folder preserve rule
drmoisan Sep 19, 2026
8f02571
docs(911): plan revision 6 - 898 merge-order branch selector and veri…
drmoisan Sep 19, 2026
8b0afe2
docs(911): plan revision 7 - anchor the branch discriminator, drop th…
drmoisan Sep 19, 2026
33a3f64
docs(911): plan revision 8 - three defects execution surfaced
drmoisan Sep 19, 2026
cf7b78b
docs(911): plan revision 9 - commit the permitted evidence forms, exa…
drmoisan Sep 19, 2026
ace08ab
docs(911): plan revision 10 - conditional summary branch, self-mainta…
drmoisan Sep 19, 2026
1ed87d6
docs(911): plan revision 11 - reachable property, corrected populations
drmoisan Sep 19, 2026
85f9a7b
docs(911): Phase 0 baselines P0-T11 through P0-T24
drmoisan Sep 20, 2026
48f0c71
fix(deps): batch A — formatting scope, #898 analyzer realignment, #90…
drmoisan Sep 20, 2026
596e7a7
fix(deps): batch B - framework compatibility, reference-sync rewrite,…
drmoisan Sep 20, 2026
6b24266
fix(deps): batch C - analyzer-item repair, project consistency and th…
drmoisan Sep 20, 2026
e3ea87b
fix(deps): batch D - composition root, repair workflow and documentation
drmoisan Sep 20, 2026
8bc97a1
docs(deps): phase 8 evidence - credential availability and deferred c…
drmoisan Sep 20, 2026
655e6ec
test(deps): phase 9 final QA loop - analyzer fix and gate evidence
drmoisan Sep 20, 2026
c677a9b
docs(deps): phase 9 close-out - review handoff index and plan check-off
drmoisan Sep 20, 2026
794d34f
docs(deps): record the P9-T16 close-out artifact
drmoisan Sep 20, 2026
b76cb8c
Merge remote-tracking branch 'origin/main' into bug/dependabot-fanout…
drmoisan Sep 20, 2026
da7a6e3
docs(911): feature-review artifacts - PARTIAL, 4 blocking
drmoisan Sep 20, 2026
ffd5395
docs(911): remediation plan for cycle 1 and the AC14 reachability note
drmoisan Sep 20, 2026
4043b91
docs(911): remediation plan preflight clearance
drmoisan Sep 20, 2026
7cda454
test(deps): cover nine untested negative and error paths in Sync-Pack…
drmoisan Sep 20, 2026
4a85800
fix(deps): make the consistency repair entry point impossible to call…
drmoisan Sep 20, 2026
07b4872
fix(ci): gate the repair push on the write set and correct the repair…
drmoisan Sep 20, 2026
597bb2f
docs(911): replace absolute host paths in committed artifacts with pl…
drmoisan Sep 20, 2026
de9a001
chore(911): record the final QA loop, coverage reconciliation and foo…
drmoisan Sep 20, 2026
2d4374e
chore(911): record the CI run at head, merge-time instructions and pl…
drmoisan Sep 20, 2026
db53ca1
docs(911): sweep the three trailing Phase 6 evidence artifacts
drmoisan Sep 20, 2026
f56dabc
fix(911): apply the five review corrections in place
drmoisan Sep 20, 2026
0399a61
docs(911): record the PR-triggered CI run that discharges G1
drmoisan Sep 25, 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
4 changes: 4 additions & 0 deletions .csharpierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@
*.csproj
*.props
*.targets
# The repository adopts the inline form the NuGet CLI writes these manifests in, so the formatter no longer owns them.
**/packages.config
# The repository adopts the inline form the NuGet CLI writes these binding-redirect files in, so the formatter no longer owns them.
**/app.config
47 changes: 15 additions & 32 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,18 @@ updates:
- "/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# One pull request at a time. Four groups at a limit of 10 produced the fan-out
# issue #911 reports: every weekly run opened several pull requests, each of which
# had to clear the full CI matrix independently (see research section 2).
open-pull-requests-limit: 1
groups:
analyzers-dev-deps:
# A single catch-all group. The four topic groups this replaces each carried an
# inert per-dependency partition key that is not a Dependabot grouping option and
# had no effect, so the grouping they appeared to express was never applied.
all-nuget-updates:
applies-to: version-updates
patterns:
- "Meziantou.Analyzer"
- "SonarAnalyzer.CSharp"
- "Roslynator.Analyzers"
- "AsyncFixer"
- "Microsoft.CodeAnalysis.BannedApiAnalyzers"
group-by: "dependency-name"
test-frameworks:
patterns:
- "MSTest.*"
- "Moq"
- "FluentAssertions"
- "Castle.Core"
- "Microsoft.Testing.*"
- "Microsoft.TestPlatform.*"
group-by: "dependency-name"
microsoft-extensions-and-bcl:
patterns:
- "Microsoft.Extensions.*"
- "Microsoft.Bcl.*"
- "System.*"
group-by: "dependency-name"
graph-identity-telemetry:
patterns:
- "Microsoft.Graph*"
- "Microsoft.Identity.*"
- "Microsoft.IdentityModel.*"
- "Azure.*"
- "OpenTelemetry*"
- "Microsoft.ApplicationInsights"
group-by: "dependency-name"
- "*"
ignore:
# Major-version bumps for Microsoft's .NET-runtime-aligned package families are
# the only observed point at which supported TFMs (net462/netstandard2.0) have
Expand All @@ -60,3 +38,8 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "Microsoft.ML*"
update-types: ["version-update:semver-major"]
# Deedle is ignored outright, at every update type. Its published packages target
# netstandard2.1 and later only, which .NET Framework 4.8.1 cannot consume at any
# version, so every Deedle pull request this repository has received has been
# unmergeable by construction.
- dependency-name: "Deedle"
56 changes: 56 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,62 @@ Structural properties that are deliberate and should not be changed casually:
`--version` output carries a build-metadata suffix that is not part of package
identity and does not resolve when passed to `--version`; do not pin it.

## Dependabot repair workflow

`dependabot-repair.yml` is not a CI gate and `ci.yml` does not invoke it. It repairs the manifest
and project-file inconsistencies a Dependabot upgrade leaves behind — a version reconciled in
`packages.config` but not in the `<Import>`, `<Error>`, `<Reference>`, `<HintPath>` and
`<Analyzer Include>` elements that depend on it — and pushes the repair onto Dependabot's own
branch so the required checks re-run on the repaired head. The repair itself lives in
`scripts/dependencies/Repair-PackageManifestConsistency.ps1`; the workflow is the wiring that
gives it a restored tree, a credential and a branch to push to.

**`app.config` binding redirects are not repaired from this trigger.** The repair script does
carry a binding-redirect reconciliation pass, but the `workflow_run` step invokes the entry point
with no `-CandidateUpgrade`, so the applied-upgrade set is always empty and that pass never runs.
An `app.config` redirect left stale by a Dependabot upgrade therefore stays stale, and an operator
investigating a binding failure after a repaired run should look there first rather than assume
the workflow covered it. The class becomes reachable only if a future change supplies
`-CandidateUpgrade` to the invocation in the "Repair package manifest consistency" step; the same
condition is recorded as a comment on that step and in the AC14 note in the issue #911 spec.

**Trigger.** The workflow triggers on `workflow_run`, on completion of the `CI` workflow, and the
job runs only when the originating run's head branch is under the `dependabot/` prefix and its
event was `pull_request`. The trigger is `workflow_run` rather than a direct `pull_request` trigger
because a run triggered directly by a Dependabot `pull_request` event receives a read-only token and
no access to repository secrets, so it cannot push. A `workflow_run` completion executes in the
base-branch context, where the credential is available. The base-context variant of the
pull-request trigger is deliberately not used: it is a security regression for a convenience gain,
and GitHub restricts it by default from 2026-11-02.

**Credential.** The workflow mints a GitHub App installation token with
`actions/create-github-app-token@v3` from two repository secrets:

| Secret | Holds |
| --- | --- |
| `DEPENDABOT_REPAIR_APP_ID` | the numeric App identifier |
| `DEPENDABOT_REPAIR_APP_PRIVATE_KEY` | the App's PEM private key |

A repository admin provisions both by hand. The procedure — creating the App, granting it contents
and pull-requests write, installing it on this repository and storing the two secrets — is in
`docs/features/active/2026-09-19-dependabot-fanout-and-ci-failing-nuget-upgrades-911/runbooks/github-app-installation-token.runbook.md`.

**Degraded mode when the credential is absent.** Until both secrets exist, the token step fails and
the job stops before it can push, so every Dependabot pull request keeps exactly the behaviour it
has today and nothing regresses. The credential matters for what happens after a repair is pushed:
a push made with the default Actions token produces a `pull_request` `synchronize` run that parks
awaiting a human approval click, because a workflow run cannot trigger another workflow run when it
is authenticated with the default token. The App identity is what makes the re-run start on its own.
The degraded mode is therefore a recurring manual approval click on every upgrade pull request
rather than a failure.

**Pinned tool version.** The workflow pins the NuGet CLI to `7.9.0`, the same literal the three
build and test gates pin, so the tool that rewrites `.csproj` and `app.config` during a restore is a
known quantity for a given commit. A Pester assertion in
`tests/scripts/dependencies/DependabotConfig.Tests.ps1` compares the literal recorded in this
section against the literal every workflow declares, so bumping the pin in one place only fails the
suite.

## Per-stage workflow_dispatch procedure

Every callee declares `workflow_dispatch` in addition to `workflow_call`, so any
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/_build-analyzers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ jobs:
- name: Setup NuGet
uses: nuget/setup-nuget@v2
with:
nuget-version: latest
# Pinned: the tool that rewrites .csproj and app.config during an upgrade must be a known quantity for a given commit, and 7.9.0 is what the floating selector resolved to, so this freezes current behaviour rather than changing it.
nuget-version: '7.9.0'

- name: Cache NuGet packages
uses: actions/cache@v4
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/_build-nullable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ jobs:
- name: Setup NuGet
uses: nuget/setup-nuget@v2
with:
nuget-version: latest
# Pinned: the tool that rewrites .csproj and app.config during an upgrade must be a known quantity for a given commit, and 7.9.0 is what the floating selector resolved to, so this freezes current behaviour rather than changing it.
nuget-version: '7.9.0'

- name: Cache NuGet packages
uses: actions/cache@v4
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/_mstest-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ jobs:
- name: Setup NuGet
uses: nuget/setup-nuget@v2
with:
nuget-version: latest
# Pinned: the tool that rewrites .csproj and app.config during an upgrade must be a known quantity for a given commit, and 7.9.0 is what the floating selector resolved to, so this freezes current behaviour rather than changing it.
nuget-version: '7.9.0'

- name: Cache NuGet packages
uses: actions/cache@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/_pester.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ jobs:
Import-Module Pester -RequiredVersion 5.6.1

$configuration = New-PesterConfiguration
$configuration.Run.Path = 'tests/scripts/vscode'
$configuration.Run.Path = @('tests/scripts/dependencies', 'tests/scripts/vscode')
$configuration.Run.PassThru = $true
$configuration.Output.Verbosity = 'Detailed'
$configuration.CodeCoverage.Enabled = $true
$configuration.CodeCoverage.Path = 'scripts/vscode'
$configuration.CodeCoverage.Path = @('scripts/dependencies', 'scripts/vscode')
$configuration.CodeCoverage.OutputFormat = 'JaCoCo'
$configuration.CodeCoverage.OutputPath = 'coverage/pester-coverage.xml'

Expand Down
173 changes: 173 additions & 0 deletions .github/workflows/dependabot-repair.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: dependabot-repair

# Repairs the manifest and project-file inconsistencies a Dependabot upgrade leaves behind, then
# pushes the repair onto Dependabot's own branch so the required checks re-run on it.
#
# The trigger is workflow_run rather than pull_request: a run triggered directly by a Dependabot
# pull_request event receives a read-only token and no access to repository secrets, so it cannot
# push. A workflow_run completion executes in the base-branch context, where the App credential is
# available. The base-context variant of the pull-request trigger is deliberately not used and its
# name appears nowhere in this file: it is a security regression for a convenience gain, and GitHub
# restricts it by default from 2026-11-02.
#
# Credential: a GitHub App installation token minted from DEPENDABOT_REPAIR_APP_ID and
# DEPENDABOT_REPAIR_APP_PRIVATE_KEY. When those secrets are absent the token step fails, the job
# stops before it can push, and the pull request keeps the behaviour it has today. See
# .github/workflows/README.md for the degraded mode and the installation runbook.

on:
workflow_run:
workflows: [CI]
types: [completed]

permissions:
contents: write
pull-requests: write

concurrency:
group: dependabot-repair-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false

jobs:
repair:
name: Repair package manifest consistency
runs-on: windows-latest
timeout-minutes: 45
# The branch-prefix restriction. Dependabot creates every branch under dependabot/, so this is
# what keeps the job off every other completed CI run.
if: >-
startsWith(github.event.workflow_run.head_branch, 'dependabot/') &&
github.event.workflow_run.event == 'pull_request'

env:
SOLUTION_PATH: TaskMaster.sln
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}

steps:
- name: Mint an installation token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.DEPENDABOT_REPAIR_APP_ID }}
private-key: ${{ secrets.DEPENDABOT_REPAIR_APP_PRIVATE_KEY }}

- name: Checkout the Dependabot branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_branch }}
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0

- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2

- name: Setup NuGet
uses: nuget/setup-nuget@v2
with:
# Pinned for the same reason as every other workflow here: the tool that rewrites
# .csproj and app.config during an upgrade must be a known quantity for a given commit.
nuget-version: '7.9.0'

- name: Restore solution
shell: pwsh
run: nuget restore $env:SOLUTION_PATH

- name: Repair package manifest consistency
id: repair
shell: pwsh
run: |
$result = & "$env:GITHUB_WORKSPACE\scripts\dependencies\Repair-PackageManifestConsistency.ps1"
if (-not $result.IsSuccess) {
$result.Failure | ForEach-Object { Write-Error "$($_.ProjectName): $($_.Condition)" }
exit 1
}
$kind = @($result.Verification | ForEach-Object { $_.Report.Repair } |
Where-Object { $null -ne $_ } | ForEach-Object { $_.Kind })
# The binding-redirect class is not reachable from the workflow_run trigger: this step
# invokes the repair entry point with no -CandidateUpgrade, so the applied-upgrade set
# is always empty, the app.config reconciliation pass never runs, and the call site
# keeps only the reconciled .Text and discards the Kind = 'BindingRedirect' record.
# No record of that kind can reach this filter, so the clause excluding it is removed.
# The decision is recorded in the AC14 note in spec.md and in the code review dated
# 2026-09-20. If a later change supplies -CandidateUpgrade, the removed clause becomes
# load-bearing again and must be restored.
$beyondKnownWeak = @($kind | Where-Object { $_ -ne 'Analyzer' }).Count
$body = Join-Path $env:RUNNER_TEMP 'repairs-report.md'
[System.IO.File]::WriteAllText($body, $result.Body)
"repair-count=$($result.RepairCount)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# RepairCount counts per-project repair records only. Manifest normalisation and
# binding-redirect reconciliation write files without producing one, so a run whose
# only writes fall in those classes reports zero, skips the push, and discards the
# repair while reporting "No repairs were applied." WrittenPath already carries
# every written path, so the push gate reads that instead. repair-count stays
# published because the disclosure body and the beyond-known-weak label read repair
# records rather than the write set.
"written-count=$(@($result.WrittenPath).Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"beyond-known-weak=$beyondKnownWeak" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"skip-count=$(@($result.Skipped).Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"report-path=$body" | Out-File -FilePath $env:GITHUB_OUTPUT -Append

- name: Commit and push the repair onto the Dependabot branch
if: steps.repair.outputs.written-count != '0'
shell: pwsh
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
# The commit identity is derived at run time, not written as a literal. GitHub
# resolves commits/<sha>.author.login by matching the commit author email to an
# account, and a GitHub App bot's noreply address is
# <bot-user-id>+<slug>[bot]@users.noreply.github.com where the numeric part is the
# BOT USER'S id, not the app id. Neither value is knowable when this file is
# authored, and a hand-written address matches no account, resolves author.login to
# null, and makes AC18 unsatisfiable. Both reads are guarded so a wrong assumption
# fails this step with a named error instead of producing a silent bad identity.
$slug = '${{ steps.app-token.outputs.app-slug }}'
if ([string]::IsNullOrWhiteSpace($slug)) {
throw 'dependabot-repair: the token step published no app-slug output, so the commit identity cannot be derived. See issue 914.'
}
$botLogin = $slug + '[bot]'
$botUserId = gh api "/users/$([uri]::EscapeDataString($botLogin))" --jq .id
if ([string]::IsNullOrWhiteSpace($botUserId)) {
throw "dependabot-repair: the users API returned no id for $botLogin, so the commit identity cannot be derived. See issue 914."
}
git config user.name $botLogin
git config user.email "$botUserId+$botLogin@users.noreply.github.com"
git add --update -- '*.csproj' '*/packages.config' '*/app.config'
git commit -m 'chore(deps): repair manifest and project-file consistency'
git push origin "HEAD:$env:HEAD_BRANCH"

- name: Disclose the repairs on the pull request
# A run that neither wrote nor skipped anything has nothing to disclose, and the
# unguarded step appended a "No repairs were applied." block on every completed CI
# run on the branch. skip-count is named as well as written-count because AC20
# requires the skipped block whenever the run recorded a skip, and a run that skipped
# an incompatible package without writing anything must still disclose.
if: steps.repair.outputs.written-count != '0' || steps.repair.outputs.skip-count != '0'
shell: pwsh
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
$number = @(gh pr list --head $env:HEAD_BRANCH --state open --json number --jq '.[].number')
if ($number.Count -eq 0) {
Write-Warning "No open pull request for branch $env:HEAD_BRANCH; nothing to disclose."
exit 0
}
$existing = gh pr view $number[0] --json body --jq '.body'
$report = [System.IO.File]::ReadAllText('${{ steps.repair.outputs.report-path }}')
# Replace rather than append. The repair push triggers a new CI run whose completion
# re-fires this workflow, so an appending edit grows the body without bound over the
# life of the pull request. Stripping any prior delimited block first makes the edit
# idempotent: one block, however many times the workflow runs.
$blockPattern = '(?s)<!-- dependabot-repair:begin -->.*?<!-- dependabot-repair:end -->'
# The two markers are derived from the strip pattern rather than written a second
# time, so the block this step emits and the block it strips cannot drift apart.
# Substring(4) drops the (?s) option prefix; the split is on the .*? between them.
$marker = $blockPattern.Substring(4) -split '\.\*\?'
$stripped = [regex]::Replace($existing, $blockPattern, '').TrimEnd()
$block = $marker[0] + "`n" + $report + "`n" + $marker[1]
$updated = Join-Path $env:RUNNER_TEMP 'pr-body.md'
[System.IO.File]::WriteAllText($updated, ($stripped + "`n`n" + $block))
gh pr edit $number[0] --body-file $updated
if ('${{ steps.repair.outputs.beyond-known-weak }}' -ne '0') {
gh pr edit $number[0] --add-label 'deps:autofixed'
}
Loading
Loading