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
13 changes: 13 additions & 0 deletions .github/scripts/print-log-tails.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -u
limit=32768
for file in "$@"; do
echo "::group::last ${limit} bytes of ${file}"
if [ -f "$file" ]; then
tail -c "$limit" "$file"
echo
else
echo "(no such file)"
fi
echo "::endgroup::"
done
37 changes: 37 additions & 0 deletions .github/scripts/run-shard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -uo pipefail

plan=$(realpath "$1")
shard=$2
projects=$3
root=$(dirname "$plan")
stryker="$root/node_modules/.bin/stryker"
tails="$(realpath "$(dirname "$0")")/print-log-tails.sh"

abort() {
echo "::error::stryker shard: $1"
"$tails" "$2/stderr.log" "$2/stdout.log"
exit "$3"
}

while IFS=$'\t' read -r index project; do
out="$root/reports/shards/$index/$project"
mkdir -p "$out" || abort "$project: cannot create $out" "$out" 3
cached="$root/$project/reports/stryker-incremental.json"
if [ -f "$cached" ] && [ ! -f "$out/stryker-incremental.json" ]; then
cp "$cached" "$out/stryker-incremental.json" || abort "$project: cannot seed its incremental file" "$out" 3
fi
(cd "$root/$project" && "$stryker" run \
--plan "$plan" --shard "$shard" --project "$project" \
--progressStreamFile "$out/mutation-stream.jsonl" \
--incremental --incrementalFile "$out/stryker-incremental.json" \
< /dev/null > "$out/stdout.log" 2> "$out/stderr.log")
code=$?
case $code in
0) ;;
1) echo "stryker shard: $project scored below thresholds.break over this shard's mutants; the merged report carries the project verdict" ;;
*) abort "$project exited $code" "$out" "$code" ;;
esac
[ -f "$out/mutation-stream.jsonl" ] ||
abort "$project completed without leaving its progress stream at $out/mutation-stream.jsonl" "$out" 3
done < "$projects"
86 changes: 25 additions & 61 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ name: Release gate
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: release-gate-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: false

jobs:
plan:
Expand All @@ -21,44 +20,33 @@ jobs:
outputs:
matrix: ${{ steps.publish.outputs.matrix }}
has-shards: ${{ steps.publish.outputs.has-shards }}
scope: ${{ steps.projects.outputs.scope }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
persist-credentials: false
- uses: ./.github/actions/dev-shell
- name: List the files the pull request changes
if: github.event_name == 'pull_request'
run: mkdir -p .cache && git diff --name-only HEAD^1 HEAD > .cache/changed-files.txt
- name: Discover mutation projects
uses: ./.github/actions/sandbox
with:
hosts: |
jsr.io
registry.npmjs.org
command: >-
deno run --config=scripts/deno.json --allow-read --allow-write --allow-env=MUTATION_SCOPE
scripts/stryker-plan-gate.ts projects --changed .cache/changed-files.txt
--out .cache/mutation-projects.txt --scope-out .cache/mutation-scope.txt
deno run --config=scripts/deno.json --allow-read --allow-write
scripts/stryker-plan-gate.ts projects --out .cache/mutation-projects.txt
- id: projects
name: Publish the project list and the mutation scope
run: |
echo "projects=$(cat .cache/mutation-projects.txt)" >> "$GITHUB_OUTPUT"
echo "scope=$(cat .cache/mutation-scope.txt)" >> "$GITHUB_OUTPUT"
name: Publish the project list
run: echo "projects=$(cat .cache/mutation-projects.txt)" >> "$GITHUB_OUTPUT"
- name: Plan mutation shards
if: steps.projects.outputs.projects != ''
uses: ./.github/actions/sandbox
env:
MUTATION_PROJECTS: ${{ steps.projects.outputs.projects }}
MUTATION_SCOPE: ${{ steps.projects.outputs.scope }}
with:
hosts: |
jsr.io
registry.npmjs.org
pass-env: |
MUTATION_PROJECTS
MUTATION_SCOPE
pass-env: MUTATION_PROJECTS
command: ./node_modules/.bin/stryker plan --target-seconds 900 --projects "$MUTATION_PROJECTS" --out stryker-plan.json
- name: Gate the plan
uses: ./.github/actions/sandbox
Expand All @@ -67,9 +55,8 @@ jobs:
jsr.io
registry.npmjs.org
command: >-
deno run --config=scripts/deno.json --allow-read --allow-write --allow-env=MUTATION_SCOPE
scripts/stryker-plan-gate.ts gate --plan stryker-plan.json --changed .cache/changed-files.txt
--out .cache/mutation-plan.out
deno run --config=scripts/deno.json --allow-read --allow-write
scripts/stryker-plan-gate.ts gate --plan stryker-plan.json --out .cache/mutation-plan.out
- id: publish
name: Publish the plan
run: cat .cache/mutation-plan.out >> "$GITHUB_OUTPUT"
Expand All @@ -91,7 +78,6 @@ jobs:
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
env:
CI: "true"
MUTATION_SCOPE: ${{ needs.plan.outputs.scope }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -103,14 +89,28 @@ jobs:
name: stryker-plan
path: .
- name: Stryker incremental cache
if: github.event_name != 'pull_request'
uses: actions/cache@v6
with:
path: |
packages/*/reports/stryker-incremental.json
reports/shards/**/stryker-incremental.json
key: stryker-shard-${{ matrix.shard }}-${{ github.sha }}
restore-keys: stryker-shard-${{ matrix.shard }}-
- name: Select the shard's projects
uses: ./.github/actions/sandbox
env:
MUTATION_SHARD: ${{ matrix.shard }}
with:
hosts: |
jsr.io
registry.npmjs.org
pass-env: |
MUTATION_SHARD
command: >-
mkdir -p .cache &&
deno run --config=scripts/deno.json --allow-read --allow-write
scripts/stryker-plan-gate.ts shard --plan stryker-plan.json --shard "$MUTATION_SHARD"
--out .cache/shard-projects.tsv
- name: Mutation at break 100
uses: ./.github/actions/sandbox
env:
Expand All @@ -119,8 +119,7 @@ jobs:
pass-env: |
GITHUB_ACTIONS
MUTATION_SHARD
MUTATION_SCOPE
command: ./node_modules/.bin/stryker run --plan stryker-plan.json --shard "$MUTATION_SHARD"
command: .github/scripts/run-shard.sh stryker-plan.json "$MUTATION_SHARD" .cache/shard-projects.tsv
- if: always()
uses: actions/upload-artifact@v6
with:
Expand All @@ -133,41 +132,6 @@ jobs:
name: mutation-report-shard-${{ strategy.job-index }}
path: packages/*/reports/mutation-report.*
if-no-files-found: ignore
- name: Re-run a failed shard's project runs with their output kept
if: failure()
timeout-minutes: 20
uses: ./.github/actions/sandbox
env:
MUTATION_SHARD: ${{ matrix.shard }}
with:
pass-env: |
GITHUB_ACTIONS
MUTATION_SHARD
MUTATION_SCOPE
command: |
set -uo pipefail
root="$PWD"
jq -r --arg shard "$MUTATION_SHARD" \
'.shards[] | select("\(.index)/\(.count)" == $shard) | .projects[].project' \
stryker-plan.json > .cache/shard-projects.txt
while read -r project; do
out="$root/reports/diagnostics/$project"
mkdir -p "$out"
(cd "$project" && "$root/node_modules/.bin/stryker" run --plan "$root/stryker-plan.json" \
--shard "$MUTATION_SHARD" --project "$project" \
--progressStreamFile "$out/mutation-stream.jsonl" \
--incremental --incrementalFile "$out/stryker-incremental.json" \
> "$out/stdout.log" 2> "$out/stderr.log")
echo "$project exited $?" | tee "$out/exit.txt"
done < .cache/shard-projects.txt
- if: failure()
uses: actions/upload-artifact@v6
with:
name: mutation-diagnostics-shard-${{ strategy.job-index }}
path: |
reports/diagnostics/
packages/*/stryker.log
if-no-files-found: ignore

verdict:
name: verdict · merged report
Expand All @@ -183,7 +147,7 @@ jobs:
HAS_SHARDS: ${{ needs.plan.outputs.has-shards }}
run: |
if [ "$PLAN" != success ]; then echo "::error::the plan job ended $PLAN"; exit 1; fi
if [ "$HAS_SHARDS" != true ]; then echo "no mutated file is in this change's scope"; exit 0; fi
if [ "$HAS_SHARDS" != true ]; then echo "the plan scheduled no shards"; exit 0; fi
if [ "$MUTATION" != success ]; then echo "::error::a mutation shard ended $MUTATION"; exit 1; fi
- if: needs.plan.outputs.has-shards == 'true'
uses: actions/checkout@v7
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pnpm check:ci
nix build .#workspace-tarballs
```

Mutation testing is not part of `pnpm check:ci`. The release gate (`.github/workflows/release-gate.yml`) runs `stryker plan` once over every workspace package that declares a `mutation` script, then `stryker run` for each planned shard at a break threshold of 100 on every push to `main`. The same workflow runs on every pull request, scoped to what the pull request changes. It mutates the declared files the pull request touches. It mutates a package's whole declared set when the pull request touches any other file in that package: a test, a fixture, a config, or a source file outside the set. It mutates every package's whole set when the pull request changes the gate itself: the workflow, `scripts/stryker-plan-gate.ts`, `stryker.shared.ts`, the lockfile or the Nix toolchain. A pull request that touches no mutated package plans nothing, and its verdict job passes. Pull-request runs skip the incremental cache, so every scoped mutant runs fresh.
Mutation testing is not part of `pnpm check:ci`, and pull requests do not run it. The release gate (`.github/workflows/release-gate.yml`) runs on every push to `main`: `stryker plan` once over every workspace package that declares a `mutation` script, then, in each planned shard, every project's `stryker run --shard --project` child at a break threshold of 100, with its stdout and stderr kept beside its progress stream (`.github/scripts/run-shard.sh`). A survivor on `main` gets a behaviour test in a follow-up pull request, and the next gate run proves it.

## Pull Requests & Commits

Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ Each package is also its own flake attribute, named after the last segment of it

## Toolchain

| Tool | Role |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Nix** | Pins Node.js 24, pnpm 12.9.0, Deno and dprint, and builds the package tarballs |
| **Sandbox** | pnpm-release-management's deny-by-default launcher; all dependency code runs in it |
| **pnpm + Turbo** | Workspace catalog with exact pins, cached task graph |
| **TypeScript 7 (tsgo)** | Typechecking through `@effect/tsgo` |
| **oxlint** | The `@systemfsoftware/oxlint-config-recommended` preset, at error severity |
| **Vitest** | Unit and integration tests |
| **Stryker** | Mutation testing at a break threshold of 100, on `main` and scoped to each pull request's change (the release gate) |
| **dprint** | Formatting for code and Markdown |
| Tool | Role |
| ----------------------- | ---------------------------------------------------------------------------------- |
| **Nix** | Pins Node.js 24, pnpm 12.9.0, Deno and dprint, and builds the package tarballs |
| **Sandbox** | pnpm-release-management's deny-by-default launcher; all dependency code runs in it |
| **pnpm + Turbo** | Workspace catalog with exact pins, cached task graph |
| **TypeScript 7 (tsgo)** | Typechecking through `@effect/tsgo` |
| **oxlint** | The `@systemfsoftware/oxlint-config-recommended` preset, at error severity |
| **Vitest** | Unit and integration tests |
| **Stryker** | Mutation testing at a break threshold of 100, on `main` only (the release gate) |
| **dprint** | Formatting for code and Markdown |

## Contributing

Expand Down
46 changes: 40 additions & 6 deletions sandbox-proofs/release-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ const UPLOAD_ARTIFACT = 'actions/upload-artifact@v6'
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@v6'
const GLOB_SEGMENT = /[*?[]/
const JOB_INDEX = '${{ strategy.job-index }}'
const SHARD_RUNNER = '.github/scripts/run-shard.sh'
const SHARD_SELECTOR = 'stryker-plan-gate.ts shard'

interface Plan {
readonly version: number
Expand All @@ -134,6 +136,15 @@ const flagOf = (command: string, flag: string): string => {
return value
}

const shardRunnerPlanOf = (command: string): string => {
const words = command.split(/\s+/)
const plan = words[words.indexOf(SHARD_RUNNER) + 1]
if (!words.includes(SHARD_RUNNER) || plan === undefined) {
throw new Error(`${JSON.stringify(command)} passes no plan to ${SHARD_RUNNER}`)
}
return plan
}

type StepInputs = NonNullable<typeof Job.Type.steps[number]['with']>

const onlyArtifactStep = async (
Expand Down Expand Up @@ -197,7 +208,7 @@ const downloadedPlanOf = async (job: JobName): Promise<string> => {
}

const shardArtifactTree = async (plan: Plan): Promise<ReadonlyMap<string, string>> => {
const shardPlan = flagOf((await onlySandboxStep('mutation', 'stryker run')).command, '--plan')
const shardPlan = shardRunnerPlanOf((await onlySandboxStep('mutation', SHARD_RUNNER)).command)
const downloadedPlan = await downloadedPlanOf('mutation')
if (downloadedPlan !== join(shardPlan)) {
throw new Error(`a shard job downloads the plan to ${downloadedPlan} but runs ${shardPlan}`)
Expand Down Expand Up @@ -239,6 +250,10 @@ const writeFixture = async (): Promise<string> => {
for (const file of ['deno.json', 'deno.lock', 'stryker-plan-gate.ts']) {
await Deno.copyFile(`${repoRoot}scripts/${file}`, `${root}/scripts/${file}`)
}
await Deno.mkdir(`${root}/.github/scripts`, { recursive: true })
for (const file of ['run-shard.sh', 'print-log-tails.sh']) {
await Deno.copyFile(`${repoRoot}.github/scripts/${file}`, `${root}/.github/scripts/${file}`)
}
await Deno.writeTextFile(`${root}/pnpm-workspace.yaml`, 'packages:\n - packages/*\n')
await Deno.writeTextFile(
`${root}/debt-ledger.yaml`,
Expand Down Expand Up @@ -282,9 +297,10 @@ Deno.test('each release-gate planner step runs in the sandbox on only its declar
}
})

Deno.test('the release-gate mutation step passes stryker every variable its main-CI guard reads, and never the local override', async () => {
Deno.test('the release-gate shard steps pass stryker every variable its main-CI guard reads, never the local override, and leave each project stream where the shard artifact uploads it', async () => {
const required = [...await guardEnvReads()].filter((name) => name !== GUARD_OVERRIDE)
const step = await onlySandboxStep('mutation', 'stryker run')
const select = await onlySandboxStep('mutation', SHARD_SELECTOR)
const step = await onlySandboxStep('mutation', SHARD_RUNNER)
const missing = required.filter((name) => !step.passEnv.includes(name))
if (missing.length > 0 || step.passEnv.includes(GUARD_OVERRIDE)) {
throw new Error(
Expand All @@ -293,10 +309,23 @@ Deno.test('the release-gate mutation step passes stryker every variable its main
}
const root = await writeFixture()
try {
await writeStrykerStub(root, 'env > .cache/stryker-env.txt\n')
await writeStrykerStub(
root,
[
'while [ $# -gt 0 ]; do',
' case $1 in --plan) plan=$2; shift ;; --progressStreamFile) stream=$2; shift ;; esac',
' shift',
'done',
'env > "$(dirname "$plan")/.cache/stryker-env.txt"',
': > "$stream"',
'',
].join('\n'),
)
const runner = Object.fromEntries([...required, GUARD_OVERRIDE].map((name) => [name, `runner-${name}`]))
const outcome = await inSandbox(step, root, { ...runner, MUTATION_SHARD: '1/1' })
if (outcome.code !== 0) throw new Error(`"${step.name}" exited ${outcome.code}:\n${outcome.out}`)
for (const shardStep of [select, step]) {
const outcome = await inSandbox(shardStep, root, { ...runner, MUTATION_SHARD: '1/1' })
if (outcome.code !== 0) throw new Error(`"${shardStep.name}" exited ${outcome.code}:\n${outcome.out}`)
}
const seen = new Map(
(await Deno.readTextFile(`${root}/.cache/stryker-env.txt`)).split('\n')
.filter((line) => line.includes('='))
Expand All @@ -306,6 +335,11 @@ Deno.test('the release-gate mutation step passes stryker every variable its main
if (lost.length > 0 || seen.has(GUARD_OVERRIDE)) {
throw new Error(`stryker lost [${lost}]${seen.has(GUARD_OVERRIDE) ? ` and saw ${GUARD_OVERRIDE}` : ''}`)
}
const marker = await strykerConstant('SHARD_OUT_MARKER')
const stream = join(root, dirname(shardRunnerPlanOf(step.command)), marker, '1', PROJECT, STREAM_FILE)
await Deno.stat(stream).catch(() => {
throw new Error(`the shard runner left no progress stream at ${stream}`)
})
} finally {
await Deno.remove(root, { recursive: true })
}
Expand Down
Loading
Loading