Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,51 @@ jobs:
}
shell: powershell

# ----- crossgen2 (ReadyToRun) compilation -----
# Stock dotnet/wpf DLLs in Microsoft.WindowsDesktop.App ship with R2R native
# code baked in by dotnet/runtime's runtime-pack assembly step. Our fork
# builds the libraries via build.cmd but does not run that step, so the DLLs
# in artifacts/bin are JIT-only. JIT'd frames are slightly fatter than R2R'd
# frames, and WPF code paths that are already deep on the stack
# (dispatcher unhandled-exception handler -> MessageDialog.xaml -> BAML ->
# WPFLocalizeExtension's culture iteration) overflow the 1 MB thread stack
# in consumers. R2R-compile the staged DLLs in place to match upstream behavior.
- name: R2R-compile staged DLLs (crossgen2)
run: |
$rid = if ("${{ matrix.arch }}" -eq "x64") { "win-x64" } else { "win-arm64" }
# Locate runtime packs from the SDK installed by setup-dotnet.
$sdkInfo = (& dotnet --info) -join "`n"
$dotnetRoot = $env:DOTNET_ROOT
if (-not $dotnetRoot -or -not (Test-Path $dotnetRoot)) {
$dotnetRoot = Split-Path -Parent (Get-Command dotnet).Source
}
$netCorePack = Get-ChildItem (Join-Path $dotnetRoot 'shared\Microsoft.NETCore.App') -Directory |
Sort-Object Name -Descending | Select-Object -First 1
$desktopPack = Get-ChildItem (Join-Path $dotnetRoot 'shared\Microsoft.WindowsDesktop.App') -Directory |
Sort-Object Name -Descending | Select-Object -First 1
if (-not $netCorePack) { throw "Microsoft.NETCore.App runtime pack not found under $dotnetRoot" }
if (-not $desktopPack) { throw "Microsoft.WindowsDesktop.App runtime pack not found under $dotnetRoot" }
Write-Host "Using NetCorePack = $($netCorePack.FullName)"
Write-Host "Using DesktopPack = $($desktopPack.FullName)"

$stagingDirs = @(
"packaging\InitialForce.WPF\runtimes\$rid\lib\net10.0",
"packaging\InitialForce.WPF.RuntimeOverride\runtimes\$rid\lib\net10.0"
)
foreach ($dir in $stagingDirs) {
Write-Host ""
Write-Host "==================================================================="
Write-Host "Crossgen2: $dir"
Write-Host "==================================================================="
& .\tools\crossgen-staged.ps1 `
-StagingDir $dir `
-NetCorePack $netCorePack.FullName `
-DesktopPack $desktopPack.FullName `
-TargetArch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { throw "crossgen-staged.ps1 failed for $dir" }
}
shell: powershell

- name: Compute package version
id: version
run: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json

# Tooling cache (downloaded crossgen2 nupkg, etc.)
.tools-cache/

# StyleCop
StyleCopReport.xml

Expand Down
25 changes: 25 additions & 0 deletions .if-fork/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ author_allowlist:
- akshatsinha0
- dotnet-maestro # automated dep updates only

# Upstream commits/PRs that must NEVER be cherry-picked back into the fork.
# Each entry refuses both the listed upstream commit SHA and any further commit
# whose message contains "(cherry picked from commit <sha>)" pointing at it.
# Cherry-pick automation must consult this list before applying any patch.
#
# Format: list of objects with:
# sha: upstream commit SHA (40 hex chars; matched as prefix on first 12+ chars)
# pr: (optional) upstream PR number for cross-reference
# reason: short human-readable rationale
# added: ISO date the entry was added
commit_denylist:
- sha: 0e965e795ee813ea17bf1bfaf3b8db9a0e0f9a8a
pr: 10715
reason: >
Substitutes typeof(CanExecuteChangedEventManager) for
typeof(RequerySuggestedEventManager) as the WeakEventManager dictionary key
in CommandManager.RequerySuggestedEventManager.CurrentManager. The two managers
then race for the same key; whichever registers first wins, and the cast on
the loser's lookup throws InvalidCastException at runtime under XAML parsing
paths that bind ICommand. Reproducible: any Button with a Command in a template
crashes once the cast goes the wrong way. Author claims "Risk: Low / Regression: No"
but it is a hard regression. Do not re-apply without an upstream fix that uses
each manager's own type as its key.
added: "2026-04-30"

# Files that auto-fail conflict resolution (always escalate to human).
file_denylist:
# supply chain
Expand Down
19 changes: 18 additions & 1 deletion .if-fork/prompts/cherry-pick.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Exit 0 for `graduated_upstream` (not an error). Exit 0 on success. Exit 1 on all

## Procedure

**Step 1 — Load config.** Note `config.file_denylist` and `config.review_hard_fail_patterns`.
**Step 1 — Load config.** Note `config.file_denylist`, `config.commit_denylist`, and `config.review_hard_fail_patterns`.

**Step 2 — Verify SHA:**
```bash
Expand All @@ -75,6 +75,23 @@ if [ "$CURRENT" != "$HEAD_SHA" ]; then
fi
```

**Step 2.5 — Commit denylist check.** Refuse the pick (exit 1) if the upstream
SHA, or any SHA reached by walking `(cherry picked from commit ...)` trailers,
is on `config.commit_denylist`. This catches regressions that were already
identified and reverted in this fork.
```bash
python tools/check-commit-denylist.py "$HEAD_SHA" --config "$CONFIG_PATH"
RC=$?
if [ $RC -eq 2 ]; then
python tools/ledger-event.py --event refused --pr-number $PR_NUMBER \
--head-sha "$HEAD_SHA" --actor cherry-pick \
--details-json '{"reason":"commit_denylist"}'
exit 1
elif [ $RC -ne 0 ]; then
exit 1
fi
```

**Step 3 — Pre-flight graduation check:**
```bash
git fetch upstream refs/pull/$PR_NUMBER/head:refs/remotes/pr/$PR_NUMBER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -901,13 +901,13 @@ private static RequerySuggestedEventManager CurrentManager
{
get
{
RequerySuggestedEventManager manager = (RequerySuggestedEventManager)GetCurrentManager(typeof(CanExecuteChangedEventManager));
RequerySuggestedEventManager manager = (RequerySuggestedEventManager)GetCurrentManager(typeof(RequerySuggestedEventManager));

// at first use, create and register a new manager
if (manager == null)
{
manager = new RequerySuggestedEventManager();
SetCurrentManager(typeof(CanExecuteChangedEventManager), manager);
SetCurrentManager(typeof(RequerySuggestedEventManager), manager);
}

return manager;
Expand Down
159 changes: 159 additions & 0 deletions tools/check-commit-denylist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""check-commit-denylist.py — Refuse cherry-pick when the upstream commit (or any
chained "(cherry picked from commit ...)" ancestor) appears in commit_denylist.

Used by cherry-pick automation as a hard gate BEFORE applying any patch. Catches:
1. A direct cherry-pick whose --from SHA is on the list.
2. A re-pick of a fork-side commit whose body contains
"(cherry picked from commit <sha>)" pointing at a denied SHA.
3. A re-pick after the upstream commit was rebased/squashed: any of the parent
SHAs threaded through the cherry-pick trailer chain.

Exit codes:
0 — not on denylist (safe to proceed)
1 — error (bad config, missing file, subprocess failure)
2 — denied (one or more SHAs in the chain match the denylist)

Output (stdout): JSON with either:
{ "verdict": "ok", "checked_shas": [...] }
{ "verdict": "denied",
"matched": {"sha": "...", "pr": ..., "reason": "..."},
"checked_shas": [...] }
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any

import yaml

CHERRY_PICK_TRAILER = re.compile(
r"\(cherry picked from commit ([0-9a-f]{7,40})\)",
re.IGNORECASE,
)


def _die(msg: str, code: int = 1) -> None:
print(json.dumps({"verdict": "error", "error": msg}))
sys.exit(code)


def load_denylist(config_path: Path) -> list[dict[str, Any]]:
if not config_path.is_file():
_die(f"config file not found: {config_path}")
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
_die(f"failed to parse {config_path}: {exc}")
raw = data.get("commit_denylist") if isinstance(data, dict) else None
if raw is None:
return []
if not isinstance(raw, list):
_die("commit_denylist must be a list")
out: list[dict[str, Any]] = []
for entry in raw:
if not isinstance(entry, dict):
_die(f"commit_denylist entry not a mapping: {entry!r}")
sha = entry.get("sha")
if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{12,40}", sha):
_die(f"commit_denylist entry has invalid sha: {entry!r}")
out.append(entry)
return out


def collect_chain(sha: str, repo: Path) -> list[str]:
"""Return [sha, parents...] including any (cherry picked from commit X) trailers
found by walking the local commit message and any reachable upstream copies."""
seen: list[str] = []
pending = [sha.lower()]
while pending:
cur = pending.pop()
if cur in seen:
continue
seen.append(cur)
# Try to read commit message; if SHA is unknown locally, skip — we still keep
# it in seen so caller can match against denylist by prefix.
try:
msg = subprocess.check_output(
["git", "log", "-1", "--format=%B", cur],
cwd=repo,
text=True,
stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
continue
for m in CHERRY_PICK_TRAILER.finditer(msg):
pending.append(m.group(1).lower())
return seen


def is_denied(sha: str, denylist: list[dict[str, Any]]) -> dict[str, Any] | None:
sha = sha.lower()
for entry in denylist:
denied = entry["sha"].lower()
if sha.startswith(denied[:12]) or denied.startswith(sha[:12]):
return entry
return None


def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Check whether a candidate commit (and its cherry-pick ancestry) is on "
"the commit_denylist in .if-fork/config.yaml. Hard gate for cherry-pick "
"automation."
)
)
parser.add_argument(
"sha",
help="Candidate commit SHA (the upstream commit you would cherry-pick, or a "
"local commit that re-picks one). Walks (cherry picked from commit ...) "
"trailers up the chain.",
)
parser.add_argument(
"--config",
type=Path,
default=Path(".if-fork/config.yaml"),
help="Path to config.yaml (default: .if-fork/config.yaml)",
)
parser.add_argument(
"--repo",
type=Path,
default=Path("."),
help="Path to git repo root (default: current dir)",
)
args = parser.parse_args()

denylist = load_denylist(args.config)
if not denylist:
print(json.dumps({"verdict": "ok", "checked_shas": [args.sha], "note": "denylist empty"}))
return 0

chain = collect_chain(args.sha, args.repo)
for candidate in chain:
match = is_denied(candidate, denylist)
if match is not None:
print(json.dumps({
"verdict": "denied",
"matched": {
"sha": match["sha"],
"pr": match.get("pr"),
"reason": match["reason"].strip(),
},
"matched_via": candidate,
"checked_shas": chain,
}))
return 2

print(json.dumps({"verdict": "ok", "checked_shas": chain}))
return 0


if __name__ == "__main__":
sys.exit(main())
30 changes: 30 additions & 0 deletions tools/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,36 @@
"items": {"type": "string"},
"description": "Glob patterns for files that auto-fail conflict resolution"
},
"commit_denylist": {
"type": "array",
"description": "Upstream commit SHAs that must never be cherry-picked (regression list)",
"items": {
"type": "object",
"required": ["sha", "reason", "added"],
"additionalProperties": false,
"properties": {
"sha": {
"type": "string",
"pattern": "^[0-9a-f]{12,40}$",
"description": "Upstream commit SHA (matched as prefix on first 12+ chars)"
},
"pr": {
"type": "integer",
"description": "Upstream PR number for cross-reference",
"minimum": 1
},
"reason": {
"type": "string",
"description": "Human-readable rationale for the block"
},
"added": {
"type": "string",
"format": "date",
"description": "ISO date the entry was added"
}
}
}
},
"tier_predicates": {
"type": "object",
"required": ["s", "a", "b"],
Expand Down
Loading
Loading