Skip to content

build: Bump AngleSharp from 1.8.5 to 1.8.6 #362

build: Bump AngleSharp from 1.8.5 to 1.8.6

build: Bump AngleSharp from 1.8.5 to 1.8.6 #362

Workflow file for this run

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