|
| 1 | +# The scheduled/manual half of #260's CI strategy: compare mode over the five |
| 2 | +# pinned OSS repositories of #243, the large/multi-project solution controls, |
| 3 | +# the examples tree, and the Windows path forms. The fast PR gate (the |
| 4 | +# committed corpus and the C# samples) lives in ci.yml and is unchanged. |
| 5 | +# |
| 6 | +# THE RULES THIS WORKFLOW EXISTS TO ENFORCE, none of which a green tick alone |
| 7 | +# would show: |
| 8 | +# |
| 9 | +# * a target is checked out AT ITS PIN and `git rev-parse HEAD` is compared |
| 10 | +# with it before anything runs — a target that moved is a failed job, not a |
| 11 | +# newer measurement; |
| 12 | +# * the extractor runs EXACTLY ONCE per document (`own-check.sh |
| 13 | +# --emit-facts` persists the file stage 1 already wrote), and the driver |
| 14 | +# reads that file once, as bytes; |
| 15 | +# * `OWN_SHADOW_ENGINE` is always set explicitly and the result names the |
| 16 | +# adapter by sha256, so a stale build cannot stand in; |
| 17 | +# * zero documents compared is a FAILURE, and so is a declared target the run |
| 18 | +# never reached — the aggregation re-checks both against the committed |
| 19 | +# sweep definition rather than trusting the legs' own exit codes. |
| 20 | +# |
| 21 | +# Reproduction artifacts upload on failure only (#260's CI strategy). The run |
| 22 | +# summary and the per-document results upload always: they are the record. |
| 23 | +name: shadow sweep (#260) |
| 24 | + |
| 25 | +on: |
| 26 | + workflow_dispatch: |
| 27 | + schedule: |
| 28 | + # Weekly, Mondays 04:17 UTC. Off the hour on purpose — the top of the hour |
| 29 | + # is the busiest slot on shared runners. |
| 30 | + - cron: "17 4 * * 1" |
| 31 | + |
| 32 | +permissions: |
| 33 | + contents: read |
| 34 | + |
| 35 | +env: |
| 36 | + DOCUMENT_OUT: shadow-sweep |
| 37 | + |
| 38 | +jobs: |
| 39 | + # One job per DOCUMENT rather than per repository: the directory walk and the |
| 40 | + # `.sln` fan-out are different extractor paths over the same checkout, they |
| 41 | + # fail differently, and a matrix leg that covered both could not say which |
| 42 | + # half a failure belonged to. |
| 43 | + document: |
| 44 | + name: ${{ matrix.id }} |
| 45 | + runs-on: ubuntu-latest |
| 46 | + strategy: |
| 47 | + fail-fast: false |
| 48 | + matrix: |
| 49 | + include: |
| 50 | + - id: ShareX.repo |
| 51 | + target: ShareX |
| 52 | + repository: ShareX/ShareX |
| 53 | + commit: 0df9ca4d83eed9d2489048c539d7d1fc2860fdec |
| 54 | + mode: directory-walk |
| 55 | + input: "" |
| 56 | + - id: ShareX.sln |
| 57 | + target: ShareX |
| 58 | + repository: ShareX/ShareX |
| 59 | + commit: 0df9ca4d83eed9d2489048c539d7d1fc2860fdec |
| 60 | + mode: solution |
| 61 | + input: ShareX.sln |
| 62 | + - id: MahApps.Metro.repo |
| 63 | + target: MahApps.Metro |
| 64 | + repository: MahApps/MahApps.Metro |
| 65 | + commit: 72099e310bac2d12ac98fd7560b69679252519f5 |
| 66 | + mode: directory-walk |
| 67 | + input: "" |
| 68 | + - id: MahApps.Metro.sln |
| 69 | + target: MahApps.Metro |
| 70 | + repository: MahApps/MahApps.Metro |
| 71 | + commit: 72099e310bac2d12ac98fd7560b69679252519f5 |
| 72 | + mode: solution |
| 73 | + input: src/MahApps.Metro.sln |
| 74 | + - id: MaterialDesignInXamlToolkit.repo |
| 75 | + target: MaterialDesignInXamlToolkit |
| 76 | + repository: MaterialDesignInXAML/MaterialDesignInXamlToolkit |
| 77 | + commit: ef3a5ea434e39182b1848f5e11aaea6b3890581f |
| 78 | + mode: directory-walk |
| 79 | + input: "" |
| 80 | + - id: AvalonEdit.repo |
| 81 | + target: AvalonEdit |
| 82 | + repository: icsharpcode/AvalonEdit |
| 83 | + commit: ed0bd149059469ac9bd39b13cf8a341b12a6c1da |
| 84 | + mode: directory-walk |
| 85 | + input: "" |
| 86 | + - id: AvalonEdit.sln |
| 87 | + target: AvalonEdit |
| 88 | + repository: icsharpcode/AvalonEdit |
| 89 | + commit: ed0bd149059469ac9bd39b13cf8a341b12a6c1da |
| 90 | + mode: solution |
| 91 | + input: ICSharpCode.AvalonEdit.sln |
| 92 | + - id: ClosedXML.repo |
| 93 | + target: ClosedXML |
| 94 | + repository: ClosedXML/ClosedXML |
| 95 | + commit: 4e89dcedd83cad553e84d2d97f77fc3d7deb630f |
| 96 | + mode: directory-walk |
| 97 | + input: "" |
| 98 | + - id: ClosedXML.sln |
| 99 | + target: ClosedXML |
| 100 | + repository: ClosedXML/ClosedXML |
| 101 | + commit: 4e89dcedd83cad553e84d2d97f77fc3d7deb630f |
| 102 | + mode: solution |
| 103 | + input: ClosedXML.sln |
| 104 | + steps: |
| 105 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 106 | + # The target, AT ITS PIN. `persist-credentials: false` because nothing in |
| 107 | + # this job pushes anywhere and a third-party checkout has no business |
| 108 | + # carrying a token. |
| 109 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 110 | + with: |
| 111 | + repository: ${{ matrix.repository }} |
| 112 | + ref: ${{ matrix.commit }} |
| 113 | + path: targets/${{ matrix.target }} |
| 114 | + persist-credentials: false |
| 115 | + - name: Verify the pin |
| 116 | + # Drift is a hard failure of this leg, never a warning: a target that |
| 117 | + # moved has not been measured, it has been replaced. |
| 118 | + run: | |
| 119 | + head=$(git -C "targets/${{ matrix.target }}" rev-parse HEAD) |
| 120 | + echo "${{ matrix.target }} HEAD=$head pin=${{ matrix.commit }}" |
| 121 | + test "$head" = "${{ matrix.commit }}" || { |
| 122 | + echo "PIN DRIFT: ${{ matrix.target }} is at $head, the sweep pins ${{ matrix.commit }}" |
| 123 | + exit 1 |
| 124 | + } |
| 125 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 126 | + with: |
| 127 | + python-version: "3.13" |
| 128 | + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 |
| 129 | + with: |
| 130 | + dotnet-version: "8.0.x" |
| 131 | + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 |
| 132 | + with: |
| 133 | + toolchain: stable |
| 134 | + # The WindowsDesktop reference pack, materialized exactly the way the |
| 135 | + # corpus-benchmark job does it. NOT continue-on-error here: the pinned |
| 136 | + # targets are WPF/WinForms, so a run without the refs measures a |
| 137 | + # different document than the sweep says it measured. |
| 138 | + - name: Materialize framework reference assemblies |
| 139 | + run: | |
| 140 | + tmp=$(mktemp -d) |
| 141 | + printf '%s\n' \ |
| 142 | + '<Project Sdk="Microsoft.NET.Sdk">' \ |
| 143 | + ' <PropertyGroup>' \ |
| 144 | + ' <TargetFramework>net8.0-windows</TargetFramework>' \ |
| 145 | + ' <UseWPF>true</UseWPF>' \ |
| 146 | + ' <UseWindowsForms>true</UseWindowsForms>' \ |
| 147 | + ' <EnableWindowsTargeting>true</EnableWindowsTargeting>' \ |
| 148 | + ' </PropertyGroup>' \ |
| 149 | + '</Project>' > "$tmp/ref.csproj" |
| 150 | + dotnet restore "$tmp/ref.csproj" >/dev/null |
| 151 | + d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' | sort | tail -1) |
| 152 | + test -n "$d" || { echo "the WindowsDesktop reference pack did not resolve"; exit 1; } |
| 153 | + echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV" |
| 154 | + echo "framework refs: $d ($(find "$d" -name '*.dll' | wc -l) dlls)" |
| 155 | + - name: Build the dev-only engine adapter |
| 156 | + working-directory: rust |
| 157 | + run: cargo build --release -p own-shadow --bin own-shadow-engine |
| 158 | + # ONE extraction. `--emit-facts` persists the file stage 1 already wrote; |
| 159 | + # stage 2 is the reference's verdict path and is neither a second |
| 160 | + # extraction nor the comparison. |
| 161 | + - name: Extract the OwnIR facts, exactly once |
| 162 | + run: | |
| 163 | + input="targets/${{ matrix.target }}" |
| 164 | + if [ -n "${{ matrix.input }}" ]; then |
| 165 | + input="targets/${{ matrix.target }}/${{ matrix.input }}" |
| 166 | + fi |
| 167 | + echo "extracting: $input" |
| 168 | + scripts/own-check.sh --format sarif --severity warning \ |
| 169 | + --emit-facts "$RUNNER_TEMP/facts.json" -- "$input" \ |
| 170 | + > "$RUNNER_TEMP/findings.sarif.json" |
| 171 | + ls -l "$RUNNER_TEMP/facts.json" |
| 172 | + - name: Describe the document |
| 173 | + run: | |
| 174 | + python - <<'PY' |
| 175 | + import hashlib, json, os |
| 176 | + facts = os.path.join(os.environ["RUNNER_TEMP"], "facts.json") |
| 177 | + with open(facts, "rb") as f: |
| 178 | + digest = hashlib.sha256(f.read()).hexdigest() |
| 179 | + target = "${{ matrix.target }}" |
| 180 | + rel = "${{ matrix.input }}" |
| 181 | + where = f"targets/{target}" + (f"/{rel}" if rel else "") |
| 182 | + manifest = { |
| 183 | + "schema": 1, |
| 184 | + "targets": [target], |
| 185 | + "documents": [{ |
| 186 | + "source": facts, |
| 187 | + "target": target, |
| 188 | + "target_commit": "${{ matrix.commit }}", |
| 189 | + "extraction_mode": "${{ matrix.mode }}", |
| 190 | + "extraction_command": ( |
| 191 | + "OWN_EXTRA_REF_DIRS=<WindowsDesktop ref pack net8.0> " |
| 192 | + "scripts/own-check.sh --format sarif --severity warning " |
| 193 | + f"--emit-facts <FACTS> -- {where}"), |
| 194 | + "facts_sha256": digest, |
| 195 | + "timeout_seconds": 600.0, |
| 196 | + }], |
| 197 | + } |
| 198 | + with open(os.path.join(os.environ["RUNNER_TEMP"], "manifest.json"), "w") as f: |
| 199 | + json.dump(manifest, f, indent=2) |
| 200 | + print(json.dumps(manifest, indent=2)) |
| 201 | + PY |
| 202 | + - name: Compare mode over the document |
| 203 | + env: |
| 204 | + OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine |
| 205 | + run: | |
| 206 | + python scripts/shadow_compare.py --engine compare \ |
| 207 | + --manifest "$RUNNER_TEMP/manifest.json" \ |
| 208 | + --out "$RUNNER_TEMP/$DOCUMENT_OUT" |
| 209 | + # The record uploads whether or not the run agreed; a leg that only |
| 210 | + # uploaded on failure would leave the aggregation unable to tell a green |
| 211 | + # target from a skipped one. |
| 212 | + - name: Upload the run record |
| 213 | + if: always() |
| 214 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 |
| 215 | + with: |
| 216 | + name: shadow-sweep-${{ matrix.id }} |
| 217 | + path: ${{ runner.temp }}/${{ env.DOCUMENT_OUT }} |
| 218 | + retention-days: 30 |
| 219 | + if-no-files-found: warn |
| 220 | + |
| 221 | + # The examples tree: cheap, named in #260's test matrix, and the one document |
| 222 | + # of this sweep that needs no third-party checkout. |
| 223 | + examples: |
| 224 | + name: examples |
| 225 | + runs-on: ubuntu-latest |
| 226 | + steps: |
| 227 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 228 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 229 | + with: |
| 230 | + python-version: "3.13" |
| 231 | + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 |
| 232 | + with: |
| 233 | + dotnet-version: "8.0.x" |
| 234 | + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 |
| 235 | + with: |
| 236 | + toolchain: stable |
| 237 | + - name: Build the dev-only engine adapter |
| 238 | + working-directory: rust |
| 239 | + run: cargo build --release -p own-shadow --bin own-shadow-engine |
| 240 | + - name: Extract the OwnIR facts, exactly once |
| 241 | + run: | |
| 242 | + scripts/own-check.sh --format sarif --severity warning \ |
| 243 | + --emit-facts "$RUNNER_TEMP/facts.json" -- examples \ |
| 244 | + > "$RUNNER_TEMP/findings.sarif.json" |
| 245 | + - name: Describe the document |
| 246 | + run: | |
| 247 | + python - <<'PY' |
| 248 | + import hashlib, json, os |
| 249 | + facts = os.path.join(os.environ["RUNNER_TEMP"], "facts.json") |
| 250 | + with open(facts, "rb") as f: |
| 251 | + digest = hashlib.sha256(f.read()).hexdigest() |
| 252 | + manifest = { |
| 253 | + "schema": 1, |
| 254 | + "targets": ["examples"], |
| 255 | + "documents": [{ |
| 256 | + "source": facts, |
| 257 | + "target": "examples", |
| 258 | + "target_commit": os.environ["GITHUB_SHA"], |
| 259 | + "extraction_mode": "directory-walk", |
| 260 | + "extraction_command": ( |
| 261 | + "scripts/own-check.sh --format sarif --severity warning " |
| 262 | + "--emit-facts <FACTS> -- examples"), |
| 263 | + "facts_sha256": digest, |
| 264 | + "timeout_seconds": 600.0, |
| 265 | + }], |
| 266 | + } |
| 267 | + with open(os.path.join(os.environ["RUNNER_TEMP"], "manifest.json"), "w") as f: |
| 268 | + json.dump(manifest, f, indent=2) |
| 269 | + PY |
| 270 | + - name: Compare mode over the examples document |
| 271 | + env: |
| 272 | + OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine |
| 273 | + run: | |
| 274 | + python scripts/shadow_compare.py --engine compare \ |
| 275 | + --manifest "$RUNNER_TEMP/manifest.json" \ |
| 276 | + --out "$RUNNER_TEMP/$DOCUMENT_OUT" |
| 277 | + - name: Upload the run record |
| 278 | + if: always() |
| 279 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 |
| 280 | + with: |
| 281 | + name: shadow-sweep-examples |
| 282 | + path: ${{ runner.temp }}/${{ env.DOCUMENT_OUT }} |
| 283 | + retention-days: 30 |
| 284 | + if-no-files-found: warn |
| 285 | + |
| 286 | + # Windows path forms, MEASURED and nothing promised beyond what runs: the |
| 287 | + # committed-corpus gate, on a Windows runner, with an adapter built there. |
| 288 | + # The driver's own controls run too and cannot skip — that group is the one |
| 289 | + # that had never executed on Windows at all. |
| 290 | + windows-path-forms: |
| 291 | + name: windows path forms (committed corpus) |
| 292 | + runs-on: windows-latest |
| 293 | + defaults: |
| 294 | + run: |
| 295 | + shell: bash |
| 296 | + steps: |
| 297 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 298 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 299 | + with: |
| 300 | + python-version: "3.13" |
| 301 | + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 |
| 302 | + with: |
| 303 | + toolchain: stable |
| 304 | + - name: Build the dev-only engine adapter |
| 305 | + working-directory: rust |
| 306 | + run: cargo build --release -p own-shadow --bin own-shadow-engine |
| 307 | + - name: Compare mode over the committed corpus |
| 308 | + env: |
| 309 | + OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine.exe |
| 310 | + run: python scripts/shadow_compare.py --engine compare --corpus --quiet --out "$RUNNER_TEMP/shadow" |
| 311 | + - name: The compare driver's controls (adapter required) |
| 312 | + env: |
| 313 | + OWN_SHADOW_ENGINE: ${{ github.workspace }}/rust/target/release/own-shadow-engine.exe |
| 314 | + OWN_SHADOW_COMPARE_REQUIRED: "1" |
| 315 | + run: python tests/test_shadow_compare.py |
| 316 | + - name: Upload the divergence reports |
| 317 | + if: failure() |
| 318 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 |
| 319 | + with: |
| 320 | + name: shadow-sweep-windows-reports |
| 321 | + path: ${{ runner.temp }}/shadow |
| 322 | + retention-days: 14 |
| 323 | + if-no-files-found: ignore |
| 324 | + |
| 325 | + # The aggregation, and the reason it exists: every leg above can be green and |
| 326 | + # the SWEEP still be a lie, because a leg that never ran uploads nothing and a |
| 327 | + # matrix that lost an entry says nothing at all. This job assembles one run |
| 328 | + # record from every leg and checks it against the committed sweep DEFINITION — |
| 329 | + # the denominator lives there, not in whatever happened to be measured. |
| 330 | + aggregate: |
| 331 | + name: aggregate the sweep |
| 332 | + runs-on: ubuntu-latest |
| 333 | + needs: [document, examples, windows-path-forms] |
| 334 | + if: always() |
| 335 | + steps: |
| 336 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 337 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 338 | + with: |
| 339 | + python-version: "3.13" |
| 340 | + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 |
| 341 | + with: |
| 342 | + pattern: shadow-sweep-* |
| 343 | + path: ${{ runner.temp }}/legs |
| 344 | + - name: Every leg must have run |
| 345 | + run: | |
| 346 | + echo "document: ${{ needs.document.result }}" |
| 347 | + echo "examples: ${{ needs.examples.result }}" |
| 348 | + echo "windows path forms: ${{ needs['windows-path-forms'].result }}" |
| 349 | + fail=0 |
| 350 | + for r in "${{ needs.document.result }}" "${{ needs.examples.result }}" \ |
| 351 | + "${{ needs['windows-path-forms'].result }}"; do |
| 352 | + [ "$r" = "success" ] || fail=1 |
| 353 | + done |
| 354 | + test "$fail" -eq 0 || { echo "a leg of the sweep did not succeed"; exit 1; } |
| 355 | + - name: Assemble one run record |
| 356 | + run: | |
| 357 | + python tests/shadow_sweep.py --collect "$RUNNER_TEMP/legs" \ |
| 358 | + --write "$RUNNER_TEMP/p022-shadow-sweep.result.json" \ |
| 359 | + --workflow-run-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" |
| 360 | + - name: Check it against the committed sweep definition |
| 361 | + run: python tests/shadow_sweep.py --result "$RUNNER_TEMP/p022-shadow-sweep.result.json" |
| 362 | + - name: Upload the run summary |
| 363 | + if: always() |
| 364 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 |
| 365 | + with: |
| 366 | + name: shadow-sweep-run-summary |
| 367 | + path: ${{ runner.temp }}/p022-shadow-sweep.result.json |
| 368 | + retention-days: 90 |
| 369 | + if-no-files-found: warn |
0 commit comments