Batch cherry-pick: 9 h3xds1nz upstream PRs (clean apply onto if/main) #71
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .github/workflows/build.yml | |
| name: Build | |
| on: | |
| pull_request: | |
| branches: | |
| - if/main | |
| - if/staging | |
| paths-ignore: | |
| - "docs/**" | |
| - "*.md" | |
| push: | |
| branches: | |
| - if/main | |
| - if/staging | |
| workflow_dispatch: | |
| concurrency: | |
| group: build-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| checks: write | |
| id-token: write | |
| packages: write | |
| env: | |
| IF_REPO_ROOT: ${{ github.workspace }} | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # lint-tools: Python tooling quality gate (ubuntu runner) | |
| # --------------------------------------------------------------------------- | |
| lint-tools: | |
| name: Lint Python tools | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install lint tools | |
| # jsonschema is required by tools/check-config-schema.py for full | |
| # draft 2020-12 validation (without it the validator falls back to a | |
| # type-only stdlib check that misses enum constraints, so e.g. | |
| # schema_version=99 would be incorrectly accepted). | |
| run: pip install ruff mypy pytest pyyaml types-PyYAML jsonschema | |
| - name: Set up ephemeral GPG signing key for ledger tests | |
| # tools/ledger-event.py fails closed in CI environments when git | |
| # commit signing fails (HIGH-2 fix). Tests that exercise the real | |
| # ledger-event subprocess therefore need a GPG key on the runner. | |
| # Generate an ephemeral throw-away key valid for one day; it never | |
| # leaves the runner and is overwritten on every CI run. | |
| run: | | |
| set -euo pipefail | |
| cat > /tmp/gpg-batch <<'EOF' | |
| %no-protection | |
| Key-Type: RSA | |
| Key-Length: 2048 | |
| Name-Real: CI Test | |
| Name-Email: ci-test@example.com | |
| Expire-Date: 1 | |
| %commit | |
| EOF | |
| gpg --batch --gen-key /tmp/gpg-batch | |
| rm /tmp/gpg-batch | |
| KEY_ID=$(gpg --list-secret-keys --keyid-format=long ci-test@example.com | awk '/^sec/ {split($2,a,"/"); print a[2]; exit}') | |
| echo "GPG key id: $KEY_ID" | |
| git config --global user.name "CI Test" | |
| git config --global user.email "ci-test@example.com" | |
| git config --global user.signingkey "$KEY_ID" | |
| git config --global commit.gpgsign true | |
| - name: ruff check | |
| run: ruff check tools/ tests/ | |
| # mypy --strict applies to tools/ only (per pyproject.toml | |
| # [tool.mypy] files=["tools/*.py"]). Test code is intentionally not | |
| # type-strict — tests use yaml.safe_load() returns and parametrize | |
| # with untyped values where strict typing would add noise without | |
| # catching real bugs. | |
| - name: mypy --strict (tools) | |
| run: mypy --strict tools/ | |
| - name: pytest tools unit tests | |
| run: pytest tests/ -v --tb=short | |
| # --------------------------------------------------------------------------- | |
| # lint-yaml: YAML / actionlint gate (ubuntu runner) | |
| # --------------------------------------------------------------------------- | |
| lint-yaml: | |
| name: Lint YAML / actionlint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python (for PyYAML parse check) | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install PyYAML | |
| run: pip install pyyaml | |
| - name: Validate all workflow YAML parses | |
| run: | | |
| python - <<'EOF' | |
| import sys, glob, yaml | |
| errors = [] | |
| for path in glob.glob(".github/workflows/*.yml"): | |
| try: | |
| with open(path) as f: | |
| yaml.safe_load(f) | |
| print(f"OK: {path}") | |
| except yaml.YAMLError as e: | |
| errors.append(f"FAIL: {path}: {e}") | |
| if errors: | |
| for err in errors: | |
| print(err, file=sys.stderr) | |
| sys.exit(1) | |
| EOF | |
| - name: actionlint | |
| continue-on-error: true | |
| run: | | |
| if command -v actionlint &>/dev/null; then | |
| actionlint .github/workflows/*.yml | |
| else | |
| echo "actionlint not installed; skipping (install from https://github.com/rhysd/actionlint)" | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # build-wpf: Windows matrix build (x64 + arm64) | |
| # --------------------------------------------------------------------------- | |
| build-wpf: | |
| name: Build WPF (${{ matrix.arch }}) | |
| runs-on: windows-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: [x64, arm64] | |
| # arm64 cross-compile runs on windows-latest x64 runner; runs-on already pins to Windows. | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Cache NuGet packages | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.nuget/packages | |
| key: nuget-win-${{ matrix.arch }}-${{ hashFiles('**/packages.lock.json', 'global.json') }} | |
| restore-keys: nuget-win-${{ matrix.arch }}- | |
| - name: Set up .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| global-json-file: global.json | |
| - name: Install Strawberry Perl (idempotent) | |
| # windows-latest pre-installs strawberryperl (currently v5.42+). | |
| # Skip choco install if perl is already on PATH; otherwise install. | |
| run: | | |
| if (Get-Command perl -ErrorAction SilentlyContinue) { | |
| Write-Host "Strawberry Perl already installed: $(perl --version | Select-String -Pattern 'This is perl')" | |
| } else { | |
| choco install strawberryperl -y --no-progress | |
| } | |
| shell: powershell | |
| - name: Validate ledger schema | |
| run: python tools/ledger-validate.py --ledger .if-fork/patch-ledger.jsonl | |
| shell: bash | |
| # ----- Real upstream WPF build ----- | |
| # The fork's checkout already contains the full dotnet/wpf source | |
| # tree (since this repo IS a fork of dotnet/wpf). Run the standard | |
| # arcade build to produce the four managed assemblies that | |
| # InitialForce.WPF wraps. | |
| - name: Build upstream WPF source | |
| # build.cmd already passes -restore -build to Build.ps1; do not duplicate. | |
| run: | | |
| .\build.cmd -c Release -plat ${{ matrix.arch }} -pack /bl:artifacts/log/Release/build.${{ matrix.arch }}.binlog | |
| shell: cmd | |
| timeout-minutes: 60 | |
| - name: Locate built WPF DLLs | |
| id: locate-dlls | |
| run: | | |
| $names = @("PresentationCore", "PresentationFramework", "WindowsBase", "System.Xaml") | |
| $found = @{} | |
| foreach ($n in $names) { | |
| # Canonical path: artifacts\bin\<exact-name>\<arch>\Release\<tfm>\<name>.dll | |
| # Restrict the search to the assembly's own bin tree so Tests/ref/obj | |
| # copies cannot be picked up by accident. | |
| $root = "artifacts\bin\$n\${{ matrix.arch }}\Release" | |
| if (-not (Test-Path $root)) { | |
| Write-Error "Output dir $root does not exist; upstream build did not produce $n" | |
| Get-ChildItem -Recurse artifacts\bin -Filter "$n.dll" | Format-Table FullName | |
| exit 1 | |
| } | |
| $hit = Get-ChildItem -Recurse $root -Filter "$n.dll" -ErrorAction SilentlyContinue | | |
| Where-Object { $_.FullName -notmatch "\\ref\\|\\obj\\" } | | |
| Select-Object -First 1 | |
| if (-not $hit) { | |
| Write-Error "Could not locate $n.dll under $root" | |
| Get-ChildItem -Recurse $root -Filter "*.dll" | Format-Table FullName | |
| exit 1 | |
| } | |
| Write-Host "Found $n.dll at $($hit.FullName)" | |
| $found[$n] = $hit.FullName | |
| } | |
| # Persist paths for the next step via env | |
| "PRESENTATIONCORE_DLL=$($found.PresentationCore)" | Out-File -Append $env:GITHUB_ENV | |
| "PRESENTATIONFRAMEWORK_DLL=$($found.PresentationFramework)" | Out-File -Append $env:GITHUB_ENV | |
| "WINDOWSBASE_DLL=$($found.WindowsBase)" | Out-File -Append $env:GITHUB_ENV | |
| "SYSTEMXAML_DLL=$($found.'System.Xaml')" | Out-File -Append $env:GITHUB_ENV | |
| shell: powershell | |
| - name: Stage DLLs into both packaging trees | |
| run: | | |
| $rid = if ("${{ matrix.arch }}" -eq "x64") { "win-x64" } else { "win-arm64" } | |
| $destinations = @( | |
| "packaging\InitialForce.WPF\runtimes\$rid\lib\net10.0", | |
| "packaging\InitialForce.WPF.RuntimeOverride\runtimes\$rid\lib\net10.0" | |
| ) | |
| foreach ($dest in $destinations) { | |
| New-Item -ItemType Directory -Path $dest -Force | Out-Null | |
| foreach ($pair in @( | |
| @{ name = "PresentationCore"; path = "$env:PRESENTATIONCORE_DLL" }, | |
| @{ name = "PresentationFramework"; path = "$env:PRESENTATIONFRAMEWORK_DLL" }, | |
| @{ name = "WindowsBase"; path = "$env:WINDOWSBASE_DLL" }, | |
| @{ name = "System.Xaml"; path = "$env:SYSTEMXAML_DLL" } | |
| )) { | |
| Copy-Item $pair.path -Destination $dest | |
| $pdb = $pair.path -replace '\.dll$', '.pdb' | |
| if (Test-Path $pdb) { Copy-Item $pdb -Destination $dest } | |
| } | |
| Write-Host "==> Staged in $dest" | |
| Get-ChildItem $dest | Format-Table Name, Length | |
| } | |
| shell: powershell | |
| - name: Compute package version | |
| id: version | |
| run: | | |
| # Pre-release version in the form 10.0.0-if.<run_number>+<short_sha> | |
| $sha = "${{ github.sha }}".Substring(0, 7) | |
| $version = "10.0.0-if.${{ github.run_number }}+$sha" | |
| "PKG_VERSION=$version" | Out-File -Append $env:GITHUB_ENV | |
| Write-Host "Package version: $version" | |
| shell: powershell | |
| - name: Pack InitialForce.WPF (only on x64 host) | |
| if: matrix.arch == 'x64' | |
| run: | | |
| dotnet pack packaging\InitialForce.WPF\InitialForce.WPF.csproj ` | |
| -c Release ` | |
| -p:PackageVersion=$env:PKG_VERSION ` | |
| --output artifacts\packages | |
| shell: powershell | |
| - name: Pack InitialForce.WPF.RuntimeOverride (only on x64 host) | |
| if: matrix.arch == 'x64' | |
| run: | | |
| dotnet pack packaging\InitialForce.WPF.RuntimeOverride\InitialForce.WPF.RuntimeOverride.csproj ` | |
| -c Release ` | |
| -p:PackageVersion=$env:PKG_VERSION ` | |
| --output artifacts\packages | |
| shell: powershell | |
| - name: List produced packages | |
| run: | | |
| Get-ChildItem -Recurse artifacts\packages | Format-Table FullName, Length | |
| shell: powershell | |
| # Publish the just-packed nupkgs to nuget.org (public). | |
| # Conditions: | |
| # - Only on push events (skip pull_request) so PRs don't churn the feed | |
| # - Only on if/main (skip if/staging) | |
| # - Only on x64 (matches the existing pack gate) | |
| # --skip-duplicate makes the step idempotent if a previous run already | |
| # pushed a package with the same exact version. | |
| - name: Publish to nuget.org | |
| if: matrix.arch == 'x64' && github.event_name == 'push' && github.ref == 'refs/heads/if/main' | |
| run: | | |
| $files = Get-ChildItem artifacts\packages -Filter '*.nupkg' | |
| foreach ($f in $files) { | |
| Write-Host "Pushing $($f.Name) to nuget.org..." | |
| dotnet nuget push $f.FullName ` | |
| --source "https://api.nuget.org/v3/index.json" ` | |
| --api-key "${{ secrets.NUGET_ORG_API_KEY }}" ` | |
| --skip-duplicate | |
| } | |
| shell: powershell | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-${{ matrix.arch }} | |
| path: | | |
| artifacts/packages/**/*.nupkg | |
| artifacts/packages/**/*.snupkg | |
| packaging/InitialForce.WPF/runtimes/** | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Upload binary logs on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: binlogs-${{ matrix.arch }} | |
| path: artifacts/log/**/*.binlog | |
| retention-days: 7 | |
| # --------------------------------------------------------------------------- | |
| # smoke: 22-scenario smoke harness (needs build-wpf) | |
| # --------------------------------------------------------------------------- | |
| smoke: | |
| name: Smoke (${{ matrix.arch }}) | |
| runs-on: windows-latest | |
| needs: build-wpf | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: [x64, arm64] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-${{ matrix.arch }} | |
| path: artifacts/packages/ | |
| - name: Set up .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| global-json-file: global.json | |
| # Placeholder: run 22-scenario smoke harness. | |
| # TODO (wired by smoke-harness beads): replace with real test invocation. | |
| - name: Run smoke harness (placeholder) | |
| run: | | |
| echo "Smoke harness placeholder — test/InitialForce.WpfSmoke/ wired by other beads." | |
| echo "Expected command:" | |
| echo " dotnet test test/InitialForce.WpfSmoke/ -c Release --logger trx --results-directory smoke-results/" | |
| mkdir -p smoke-results | |
| echo '<?xml version="1.0" encoding="UTF-8"?><TestRun id="placeholder"><Results/></TestRun>' > smoke-results/smoke-placeholder.xml | |
| shell: bash | |
| - name: Upload smoke results | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: smoke-${{ matrix.arch }}.xml | |
| path: smoke-results/ | |
| retention-days: 14 | |
| # --------------------------------------------------------------------------- | |
| # perf: BenchmarkDotNet perf gate (needs build-wpf) | |
| # --------------------------------------------------------------------------- | |
| perf: | |
| name: Perf (${{ matrix.arch }}) | |
| runs-on: windows-latest | |
| needs: build-wpf | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: [x64, arm64] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-${{ matrix.arch }} | |
| path: artifacts/packages/ | |
| - name: Set up .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| global-json-file: global.json | |
| # Placeholder: run BenchmarkDotNet benchmarks. | |
| # TODO (wired by perf beads): replace with real benchmark invocation. | |
| - name: Run BenchmarkDotNet (placeholder) | |
| run: | | |
| echo "BenchmarkDotNet placeholder — wired by other beads." | |
| echo "Expected command:" | |
| echo " dotnet run --project test/InitialForce.WpfSmoke/ -c Release -- --filter '*Bench*' --exporters json" | |
| echo " python tools/check-regression.py --current-sha ${{ github.sha }} --series perf/series.jsonl" | |
| shell: bash | |
| - name: Upload perf results | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: perf-${{ matrix.arch }} | |
| path: BenchmarkDotNet.Artifacts/ | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| # --------------------------------------------------------------------------- | |
| # aggregate: diff smoke results, final gate (needs all prior jobs) | |
| # --------------------------------------------------------------------------- | |
| aggregate: | |
| name: Aggregate results | |
| runs-on: ubuntu-latest | |
| needs: [lint-tools, lint-yaml, smoke, perf] | |
| if: always() | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install Python deps | |
| run: pip install pyyaml | |
| - name: Download smoke x64 results | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: smoke-x64.xml | |
| path: /tmp/smoke-results/x64/ | |
| continue-on-error: true | |
| - name: Download smoke arm64 results | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: smoke-arm64.xml | |
| path: /tmp/smoke-results/arm64/ | |
| continue-on-error: true | |
| - name: Download baseline smoke artifact (if available) | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: smoke-baseline | |
| path: /tmp/smoke-baseline/ | |
| continue-on-error: true | |
| - name: Diff smoke results (if baseline available) | |
| run: | | |
| if [ -d /tmp/smoke-baseline ] && [ "$(ls -A /tmp/smoke-baseline)" ]; then | |
| python tools/diff-smoke-results.py \ | |
| --upstream /tmp/smoke-baseline/ \ | |
| --fork /tmp/smoke-results/x64/ \ | |
| --output /tmp/smoke-diff-report.md | |
| echo "Smoke diff report:" | |
| cat /tmp/smoke-diff-report.md | |
| else | |
| echo "No baseline artifact found; skipping diff (first run or baseline not published)." | |
| fi | |
| shell: bash | |
| - name: Check all required jobs passed | |
| run: | | |
| LINT_TOOLS="${{ needs.lint-tools.result }}" | |
| LINT_YAML="${{ needs.lint-yaml.result }}" | |
| SMOKE="${{ needs.smoke.result }}" | |
| PERF="${{ needs.perf.result }}" | |
| echo "lint-tools: $LINT_TOOLS" | |
| echo "lint-yaml: $LINT_YAML" | |
| echo "smoke: $SMOKE" | |
| echo "perf: $PERF" | |
| FAILED=0 | |
| for result in "$LINT_TOOLS" "$LINT_YAML" "$SMOKE" "$PERF"; do | |
| if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then | |
| FAILED=1 | |
| fi | |
| done | |
| if [ "$FAILED" -eq 1 ]; then | |
| echo "::error::One or more required jobs failed. See details above." | |
| exit 1 | |
| fi | |
| echo "All required jobs passed." | |
| shell: bash |