Repository navigation
build: Bump AngleSharp from 1.8.5 to 1.8.6 #362
Workflow file for this run
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
| name: mutation | |
| # Mutation testing for the FirstClassErrors libraries. JustDummies used to ride an identical workflow | |
| # of its own, written so that the repository split would be a file move rather than an edit of a shared | |
| # matrix. The split happened (ADR-0069) and the file moved; nothing here needs a twin any more. | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| # Weekly full sweep. The pull-request legs only mutate what a pull request touched, so nothing ever | |
| # re-measures the parts of the libraries no one has edited: this is the run that does. | |
| schedule: | |
| - cron: '23 3 * * 1' | |
| workflow_dispatch: | |
| # Cancel superseded runs on the same branch / PR. | |
| concurrency: | |
| group: mutation-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege is declared PER JOB below rather than here. A workflow-level grant reaches every | |
| # job, including any added later that does not need it, so each job states the narrowest scope it can: | |
| # `changed` and `full` check the repository out and take `contents: read`, the advisory `gate` checks | |
| # nothing out and declares none at all (Sonar githubactions:S8264). A job added here MUST carry its own | |
| # `permissions` block — with none it inherits the repository default instead of a floor set here. | |
| env: | |
| DOTNET_NOLOGO: 'true' | |
| DOTNET_CLI_TELEMETRY_OPTOUT: 'true' | |
| DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' | |
| jobs: | |
| # The gate. One leg per shipped library, each mutating ONLY what the pull request changed | |
| # (Stryker's `--since`), so the cost follows the diff and not the size of the library. | |
| # | |
| # `gate` below aggregates these legs into the single check to mark as required on `main`. | |
| changed: | |
| name: Mutate the diff (${{ matrix.name }}) | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| # Checks the repository out, builds and runs tests; it calls no API and writes nothing back. | |
| permissions: | |
| contents: read | |
| # A leg whose library the pull request did not touch finishes in ~2 min (analysis, build, initial | |
| # test run, mutant generation). The cap covers the worst realistic case instead: a pull request | |
| # touching one of the large files — Stryker selects per changed FILE, so `Outcome.cs` alone puts | |
| # ~280 mutants on the gate, each costing one run of the library's test suite. | |
| timeout-minutes: 60 | |
| strategy: | |
| # Run every leg to completion: a survivor in the binder is worth seeing even when core is red. | |
| fail-fast: false | |
| matrix: | |
| # Every FirstClassErrors project whose code is compiled and shipped — the three libraries AND | |
| # the tooling: the `fce` command line, the documentation generator, the Roslyn analyzers. The | |
| # `Usage` samples and the binder benchmarks stay out (samples and a measurement harness, not | |
| # shipped behaviour), and so does `GenDoc.Worker`: nothing unit-tests it in process — it is a | |
| # process entry point, exercised end to end by the `floor` job of `ci` — so mutating it would | |
| # report survivors that no test could ever kill. | |
| include: | |
| - name: core | |
| config: build/stryker/core.json | |
| - name: testing | |
| config: build/stryker/testing.json | |
| - name: binder | |
| config: build/stryker/binder.json | |
| - name: analyzers | |
| config: build/stryker/analyzers.json | |
| - name: gendoc | |
| config: build/stryker/gendoc.json | |
| - name: cli | |
| config: build/stryker/cli.json | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| # Stryker's `--since` diffs the working tree against a commit, so the full history must be | |
| # present; a shallow clone would leave that commit unreachable. | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 | |
| with: | |
| dotnet-version: '10.0.x' | |
| - name: Restore the local tools | |
| # Pins dotnet-stryker through .config/dotnet-tools.json, so CI and a maintainer's machine run | |
| # the same mutation engine — a newer engine invents new mutants and would move every score on | |
| # its own, without a line of code changing. | |
| run: dotnet tool restore | |
| # `pull_request` checks out the merge commit, so what this branch actually changed is measured | |
| # from the FORK POINT — not from the base branch tip, which may have moved on since the branch | |
| # was cut and would drag every file changed on `main` in the meantime into the "changed" set. | |
| - name: Resolve the fork point | |
| id: base | |
| # A real commit SHA, not a rev expression: `--since:HEAD` is rejected outright by Stryker | |
| # ("No branch or tag or commit found with given target"). `set -e` makes a failed merge-base | |
| # fail the step, rather than handing Stryker an empty target that fails it later and vaguer. | |
| run: | | |
| set -euo pipefail | |
| base=$(git merge-base '${{ github.event.pull_request.base.sha }}' HEAD) | |
| echo "fork point: $base" | |
| echo "sha=$base" >> "$GITHUB_OUTPUT" | |
| - name: Mutate the changed files | |
| # The runner mode, the thresholds and the reporters all live in the config file, so this is | |
| # the same command a maintainer runs locally. `--break-at` is deliberately NOT passed here: | |
| # overriding the threshold from the YAML is what would let CI and a local run disagree. | |
| # | |
| # Stryker exits 0 — "unable to calculate a mutation score" — when the diff touches none of | |
| # this library's sources, which is the common case for a leg nobody is working on. | |
| run: >- | |
| dotnet stryker --config-file ${{ matrix.config }} | |
| --since:${{ steps.base.outputs.sha }} | |
| --output artifacts/mutation/${{ matrix.name }} | |
| - name: Summarise the surviving mutants | |
| # The score alone does not say what to fix. This turns the JSON report into the actionable | |
| # half — file, line and the kind of rewrite that went unnoticed — in the run summary, so a | |
| # failing gate can be diagnosed without downloading the artifact. `if: always()` because the | |
| # interesting case is the failing one. | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" | |
| if [ ! -f "$report" ]; then | |
| echo "${{ matrix.name }}: the diff selected no mutant" | tee -a "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from | |
| # the report's contents, not from the file's absence: count the mutants that were actually | |
| # put to the test, i.e. neither filtered out nor rejected by the compiler. | |
| tested=$(jq '[.files[].mutants[] | |
| | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") | |
| rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' | |
| .files | to_entries[] | .key as $f | .value.mutants[] | |
| | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") | |
| | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" | |
| ' "$report") | |
| { | |
| echo "### Mutation — ${{ matrix.name }}" | |
| echo | |
| if [ "$tested" -eq 0 ]; then | |
| echo "This pull request changed nothing this library is mutated from." | |
| elif [ -z "$rows" ]; then | |
| echo "All $tested mutants were killed." | |
| else | |
| echo "| Status | Location | Mutation |" | |
| echo "| --- | --- | --- |" | |
| echo "$rows" | |
| fi | |
| } | tee -a "$GITHUB_STEP_SUMMARY" | |
| - name: Upload the mutation report | |
| # Always: the report is most useful precisely when the run failed the threshold — the HTML | |
| # view shows each surviving mutant in its source, which the summary table cannot. | |
| if: always() | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 | |
| with: | |
| name: mutation-report-${{ matrix.name }} | |
| path: artifacts/mutation/${{ matrix.name }}/reports/ | |
| # A leg that selected no mutant writes no report; that is a pass, not a packaging failure. | |
| if-no-files-found: ignore | |
| # The single check that reports the per-PR mutation legs for the FirstClassErrors libraries. A matrix | |
| # produces one check per leg, and the set of leg names would have to be re-declared in the branch | |
| # protection every time the matrix changes; this job collapses them into one stable name. | |
| # `if: always()` is what makes it run after a failed or cancelled leg rather than being skipped. | |
| # ADVISORY on pull requests (ADR-0046): the enforced bar is the weekly `full` sweep, so this job | |
| # summarises the diff legs without ever turning a pull request red. | |
| gate: | |
| name: Mutation gate | |
| if: always() && github.event_name == 'pull_request' | |
| needs: changed | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| # The narrowest of the three: this job checks nothing out and calls no API. | |
| # `needs.changed.result` is substituted by GitHub before the shell runs, and `::warning::` is a | |
| # runner workflow command written to stdout, not a REST call. A job-level block REPLACES whatever | |
| # the job would otherwise inherit rather than merging with it, so `{}` — GitHub's explicit "no | |
| # scopes" form — leaves this job's token with nothing at all (Sonar githubactions:S8264). It must | |
| # stay the explicit flow mapping: a bare `permissions:` is a null, not an empty map. Widen it if a | |
| # checkout or a `gh` call is ever added here, or that step will fail on a 403. | |
| permissions: {} | |
| steps: | |
| - name: Report the mutation legs | |
| # `needs.changed.result` is the matrix's aggregate: 'success' only when every leg succeeded, | |
| # 'skipped' when the whole matrix is skipped by design, 'cancelled' when a superseding push | |
| # cancelled the run. None of these blocks the merge: a genuine leg failure is surfaced as a | |
| # warning to investigate, and a cancelled superseded run is simply noise, not a failure. | |
| run: | | |
| result='${{ needs.changed.result }}' | |
| echo "mutation legs: $result" | |
| if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then | |
| echo "::warning::mutation legs did not all succeed ($result) — advisory only; see the 'Mutate the diff' legs and the weekly sweep" | |
| fi | |
| # The weekly sweep: every mutant of every FirstClassErrors library, not just the ones a pull request | |
| # touched. Advisory by construction — `--break-at 0` disables the threshold — because its job is to | |
| # publish a trend, not to turn `main` red on a Monday morning over code nobody changed. | |
| full: | |
| name: Full sweep (${{ matrix.name }}) | |
| if: github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| # Same scope as `changed`: checkout, build, test — nothing written back. | |
| permissions: | |
| contents: read | |
| # The sweep runs the project's whole test suite once per mutant, and the six projects together | |
| # carry several thousand — the tooling's suites being the slow ones, since they drive Roslyn | |
| # compilations and snapshot comparisons. That is the reason the sweep is weekly and the gate is | |
| # diff-scoped. The cap sits just under the six-hour ceiling GitHub imposes on a job: this run is | |
| # meant to take as long as it takes. | |
| timeout-minutes: 350 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: core | |
| config: build/stryker/core.json | |
| - name: testing | |
| config: build/stryker/testing.json | |
| - name: binder | |
| config: build/stryker/binder.json | |
| - name: analyzers | |
| config: build/stryker/analyzers.json | |
| - name: gendoc | |
| config: build/stryker/gendoc.json | |
| - name: cli | |
| config: build/stryker/cli.json | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 | |
| with: | |
| dotnet-version: '10.0.x' | |
| - name: Restore the local tools | |
| run: dotnet tool restore | |
| - name: Mutate everything | |
| run: >- | |
| dotnet stryker --config-file ${{ matrix.config }} | |
| --break-at 0 | |
| --output artifacts/mutation/${{ matrix.name }} | |
| - name: Summarise the surviving mutants | |
| # Same table as the gate legs produce. On the sweep it is the deliverable, not a diagnosis | |
| # aid: this is the list of behaviours nothing in the suite asserts, library by library. | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" | |
| if [ ! -f "$report" ]; then | |
| echo "${{ matrix.name }}: no report produced" | tee -a "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from | |
| # the report's contents, not from the file's absence: count the mutants that were actually | |
| # put to the test, i.e. neither filtered out nor rejected by the compiler. | |
| tested=$(jq '[.files[].mutants[] | |
| | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") | |
| rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' | |
| .files | to_entries[] | .key as $f | .value.mutants[] | |
| | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") | |
| | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" | |
| ' "$report") | |
| { | |
| echo "### Mutation sweep — ${{ matrix.name }}" | |
| echo | |
| if [ "$tested" -eq 0 ]; then | |
| echo "No mutant was selected — check the run's Stryker output." | |
| elif [ -z "$rows" ]; then | |
| echo "All $tested mutants were killed." | |
| else | |
| echo "| Status | Location | Mutation |" | |
| echo "| --- | --- | --- |" | |
| echo "$rows" | |
| fi | |
| } | tee -a "$GITHUB_STEP_SUMMARY" | |
| - name: Upload the mutation report | |
| if: always() | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 | |
| with: | |
| name: mutation-report-full-${{ matrix.name }} | |
| path: artifacts/mutation/${{ matrix.name }}/reports/ | |
| # The sweep always produces a report; nothing to upload means the run never got that far. | |
| if-no-files-found: error |