Keeping the database between CI runs¶
An incremental run is only as fast as the database it starts from, and a CI
job starts from a fresh checkout. Carry typemut.sqlite from run to run with
these rules:
- Not in git. The database is binary, changes on every run, and an
untracked or modified file in the project fails the clean-tree check of
--jobs. Keep*.sqlitein.gitignore(or pass--dboutside the project). - Only trusted runs on the default branch store it (push, schedule, manual runs). Pull/merge requests and other branches only read it: a cached kill is copied as is, so a database written by an untrusted change could hide survivors from later runs.
- Every run, the default branch included, starts from the latest database of
the default branch, whatever that run's outcome. A run that failed (a new
survivor) or was stopped by
--max-durationsaved its finished results, and the next run continues from them. That is how a project too large for one CI job gets covered: each run on main adds kills until none are left to run. - No database means a full run: correct, only slower. An expired artifact or an evicted cache costs one slow run, nothing else.
- Time limits nest:
--max-duration< the step's (script's) timeout < the job's timeout, so typemut stops starting mutants, waits for the running ones and saves, and the upload step still runs. Leave room for one mutant'stimeoutplus the baseline check between--max-durationand the step timeout. - Runs on the default branch wait for each other. Two overlapping runs
would both upload a database, and the older commit's run could finish
last and become "the latest". Queue them (a concurrency group on GitHub, a
resource_groupon GitLab), so a queued run downloads the database of the run before it when it starts, and pick the newest run's database, not the newest upload (the artifact examples do; the GitHub cache example restores the newest upload, see its caveat). A queued run waits for the one ahead of it, so a push that arrives during the weekly refresh waits until the refresh is done. - Recheck on a schedule: a weekly
typemut run --refreshon the default branch runs every mutant again and stores the results like a push. It rechecks only what fits in its--max-duration, so give it a limit (and job timeout) long enough for the whole project, within the platform's maximum job time: the GitHub examples use 5.5 hours, the GitLab one 5h40m, instead of 25 minutes.
On GitHub Actions the ready-made action (the first tab below) does all of
this but the triggers, the time limits and the concurrency group, which belong
to the workflow; the manual workflows after it spell the same steps out. typemut's
own CI uses the action: see
.github/workflows/ci.yml. (There, any change to typemut's own sources resets
the cache, since an unreleased typemut is keyed by its sources: pull requests
that touch src/typemut/ run every mutant.)
The typemut action
runs the steps of the artifact workflow (next tab): it downloads the
database of the newest run on the default branch of this repository (any
failure is a warning and a full run; a file that is not an intact SQLite
database is discarded), runs typemut run --incremental, and uploads the
database, even when typemut failed, but only from push, schedule and
workflow_dispatch runs on the default branch (pull_request_target and
every other event only read it). The workflow still has to give it:
permissions: actions: read, to list and download artifacts;- the
concurrencygroup, so that runs on the default branch upload one after another (an action cannot set it); - the job's
timeout-minutes, above the action'stimeout-minutes, which is abovemax-duration; the weeklyrefreshgets longer limits; - typemut and the type checker: install the project (
uv sync) and passcommand: uv run typemut, or setversionto install typemut withpython -m pip; *.sqlitein.gitignore, sincejobsneeds a clean git tree.
It runs on GitHub-hosted runners as is; a self-hosted one needs gh,
unzip, python3 and timeout (or gtimeout). Pin the action to a
release tag (0.5.0 or later) or a commit SHA.
name: typemut
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly --refresh
workflow_dispatch:
inputs:
refresh: # re-trigger a refresh replaced in the queue
type: boolean
default: false
permissions:
contents: read
jobs:
typemut:
runs-on: ubuntu-latest
# > timeout-minutes > max-duration; the weekly --refresh gets longer.
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 350 || 45 }}
concurrency: # runs on main upload one after another
group: typemut-db-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
actions: read # list and download artifacts
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync
- uses: nkhitrov/typemut@0.5.0
with:
command: uv run typemut
jobs: 4
baseline: typemut-baseline.json
refresh: ${{ (github.event_name == 'schedule' || inputs.refresh) && 'true' || 'false' }}
max-duration: ${{ (github.event_name == 'schedule' || inputs.refresh) && 19800 || 1500 }}
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 340 || 35 }}
| Input | Default | Description |
|---|---|---|
command |
typemut |
Command that starts typemut, split on whitespace (uv run typemut, poetry run typemut) |
version |
— | Install this typemut version (latest for the newest) with python -m pip into the Python on PATH (set it up with actions/setup-python); empty: use command as is |
working-directory |
. |
Project root, where typemut.toml lives |
db |
typemut.sqlite |
Database file (--db), relative to working-directory |
jobs |
— | --jobs |
max-duration |
— | --max-duration, in seconds |
timeout-minutes |
— | Stop typemut with SIGTERM after this many minutes; it saves its finished results like on Ctrl+C, and the upload still runs. Use it instead of a step timeout, which would also stop the upload |
refresh |
false |
--refresh: recheck every mutant |
baseline |
— | --baseline, relative to working-directory |
fail-under |
— | --fail-under |
args |
— | More typemut run options, split on whitespace |
artifact-name |
typemut-db |
Database artifact name; give each job that stores a database (a matrix) its own, or set upload: false in all but one |
retention-days |
30 |
Days to keep the uploaded database |
upload |
true |
false: never upload the database |
default-branch |
the repository's | Branch whose database is used and stored |
token |
github.token |
Token that lists and downloads artifacts (actions: read) |
Outputs: db (absolute path of the database, e.g. for typemut html --db),
db-run-id (the run the database came from; empty for a full run) and
stored (true when this run uploads it).
Artifacts are kept for retention-days (up to the repository's limit, 90 days
by default), can be found by name through the API, and a failed or timed-out
run still uploads one with if: always(). The download step looks for the
unexpired typemut-db artifact of the newest run (highest run id) on main
of this repository: head_repository_id keeps out a fork's pull request from
a branch that is also named main, and the run id keeps out a re-run of an
old run, which uploads later than newer runs. It reads the 100 most recently
uploaded typemut-db artifacts, which is plenty with one per run on main.
The concurrency group queues runs of the same branch, so a run on main
starts after the previous one has uploaded. GitHub keeps only one waiting run
per group, and a newer one cancels it. For a waiting push run that is fine:
the newer run downloads the same database and covers its commit. But a push
can also cancel a waiting weekly refresh (or wait for a running one, up to its
time limit); re-trigger a cancelled refresh by hand with the workflow's
refresh input. Don't give refreshes a group of their own: a push run started
during a refresh has a higher run id, so its database, built without the
refresh's results, would become the latest. overwrite: true lets a re-run of failed jobs, which reuses the
run, replace that run's artifact. The weekly --refresh run gets a 5.5-hour
limit (a GitHub-hosted job runs for at most 6 hours).
name: typemut
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly --refresh
workflow_dispatch:
inputs:
refresh: # re-trigger a refresh replaced in the queue
type: boolean
default: false
permissions:
contents: read
jobs:
typemut:
runs-on: ubuntu-latest
# > step timeout > --max-duration; the weekly --refresh gets longer.
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 350 || 45 }}
concurrency: # runs on main upload one after another
group: typemut-db-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
actions: read # list and download artifacts
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync
- name: Download the typemut database of main
env:
GH_TOKEN: ${{ github.token }}
REPO_ID: ${{ github.repository_id }}
run: |
# Unexpired artifact of the newest run on main of this repository
# (not of a fork), whatever that run's outcome.
query='[.artifacts[]
| select(.expired | not)
| select(.workflow_run.head_branch == "main")
| select(.workflow_run.head_repository_id == ($ENV.REPO_ID | tonumber))]
| max_by(.workflow_run.id) // empty
| "\(.id) \(.workflow_run.id)"'
if ! found="$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=typemut-db&per_page=100" --jq "$query")"; then
echo "::warning::Could not list the typemut-db artifacts: running every mutant"
exit 0
fi
if [ -z "$found" ]; then
echo "::notice::No typemut database of main: running every mutant"
exit 0
fi
read -r artifact_id run_id <<< "$found"
if gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > typemut-db.zip \
&& unzip -o -q typemut-db.zip typemut.sqlite; then
echo "::notice::typemut database from run $run_id"
else
echo "::warning::Could not download the database of run $run_id: running every mutant"
rm -f typemut.sqlite
fi
rm -f typemut-db.zip
- name: typemut
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 340 || 35 }}
env:
ARGS: ${{ (github.event_name == 'schedule' || inputs.refresh) && '--refresh --max-duration 19800' || '--max-duration 1500' }}
run: >-
uv run typemut run --incremental --jobs 4
--baseline typemut-baseline.json $ARGS
# Only trusted events on main store the database, even when typemut failed.
- name: Upload the typemut database
if: always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: typemut-db
path: typemut.sqlite
retention-days: 30
if-no-files-found: ignore
overwrite: true # a re-run of failed jobs reuses the run
Simpler, with no API calls, but caches unused for 7 days are evicted and the
repository's cache storage is limited (10 GB by default, oldest evicted first).
Each run on main saves a new cache under a unique key; every run restores the
newest one by the typemut- prefix. Pull requests can restore caches of the
base branch, and caches saved by pull requests stay scoped to them, so main
never reads one. The triggers, time limits and concurrency group are those of
the artifact workflow; only the database steps differ (no actions: read
needed).
The caveat: the restore step takes the newest saved cache, not the newest run's. The concurrency group does not stop a re-run of an older run on main (say, "Re-run failed jobs" on a run that failed): it saves last, so later runs restore it instead of the newer runs' caches. Nothing wrong gets reused, since kills are only copied for files whose hashes still match (and a changed typemut, checker version or lockfile resets the cache), but the progress of the runs in between is lost and their mutants run again. Re-run only the newest run on main, or use the artifact variant, which picks the newest run.
name: typemut
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly --refresh
workflow_dispatch:
inputs:
refresh: # re-trigger a refresh replaced in the queue
type: boolean
default: false
permissions:
contents: read
jobs:
typemut:
runs-on: ubuntu-latest
# > step timeout > --max-duration; the weekly --refresh gets longer.
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 350 || 45 }}
concurrency: # runs on main save one after another
group: typemut-db-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync
- uses: actions/cache/restore@v4
with:
path: typemut.sqlite
key: typemut-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: typemut- # the newest cache saved on main
- name: typemut
timeout-minutes: ${{ (github.event_name == 'schedule' || inputs.refresh) && 340 || 35 }}
env:
ARGS: ${{ (github.event_name == 'schedule' || inputs.refresh) && '--refresh --max-duration 19800' || '--max-duration 1500' }}
run: >-
uv run typemut run --incremental --jobs 4
--baseline typemut-baseline.json $ARGS
- uses: actions/cache/save@v4
if: always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
with:
path: typemut.sqlite
key: typemut-${{ github.run_id }}-${{ github.run_attempt }}
Three jobs share the setup: typemut runs on the default branch and keeps the
database as an artifact even when it fails (when: always); typemut-mr runs
for merge requests and keeps nothing; typemut-refresh is the weekly
--refresh from a pipeline schedule, with a longer time limit so that it
rechecks the whole project (raise the project's and the runner's maximum job
timeout to match).
Before typemut starts, ci/typemut-db.sh downloads the database of the newest
typemut or typemut-refresh job on the default branch that succeeded or
failed. GitLab's "latest artifact of a branch" endpoint only knows successful
pipelines, so the script looks the job up with glab
and jq: glab ci list gives the 50 newest pipelines of
the default branch (merge request pipelines have other refs and do not push
them out), glab ci get the jobs of each, newest first, until one has such a
job. A pipeline still running other jobs (a later stage, a deploy waiting on a
manual job) counts once its typemut job is done; pipelines are ordered by id,
so a retried job of an older pipeline does not win. glab api then fetches the
single typemut.sqlite (no zip involved), and the script keeps it only if it
is an SQLite database. No such job, an expired artifact, or any API error means
no database and a full run, never a broken file; the script never fails the
job. The first run scans all 50 pipelines, a few minutes.
Listing pipelines needs a token with the read_api scope: a project access
token with the Reporter role and a short expiry, saved as the masked CI/CD
variable TYPEMUT_API_TOKEN. Use a token of its own, not an existing
GITLAB_TOKEN, which often has the api scope. Without it, glab uses the job
token, and the script takes the typemut job of the latest successful
pipeline instead. Either:
- leave the variable unprotected, so merge request pipelines use it too. Any code in a merge request of this project can then read it, so don't run pipelines of fork merge requests in the parent project; or
- protect it (safer): only the default branch's jobs list pipelines, merge requests get the database of the latest successful pipeline, which may be older.
Either way the default branch must be protected: only its pipelines' jobs are picked, and only trusted users should be able to run them.
resource_group: typemut-db is GitLab's counterpart to the GitHub concurrency
group: the typemut and typemut-refresh jobs run one at a time, and a job
waiting for the group downloads the database when its script starts, after
the job ahead of it has uploaded. A push pipeline that starts during the
weekly refresh therefore waits for it (up to its 6h timeout). The group's
default process mode lets waiting jobs start in any order; set it to
oldest_first (glab api --method PUT "projects/<id>/resource_groups/typemut-db"
--field process_mode=oldest_first) so they run in pipeline order and the
newest pipeline's database is also the last one written.
RUNNER_SCRIPT_TIMEOUT below timeout leaves the runner time to upload the
artifact after a script timeout; it needs GitLab Runner 16.4 or later (older
runners ignore it, and a job that hits timeout uploads nothing).
The .glab preset installs jq and a pinned glab release (checked against its
SHA-256, amd64 runners) and turns on glab's CI auto-login (glab 1.90 or later).
Its before_script replaces a default: before_script. In production, pin the
image by digest, or bake glab and jq into your own image and drop .glab.
# glab and jq; glab logs in with the job token (CI auto-login).
.glab:
variables:
GLAB_VERSION: "1.92.1"
GLAB_SHA256: "18048e5cb2cbc92eb31d4190852c6da32f6713633cfefc7b3fe00c18806c4f53" # linux_amd64.deb
GLAB_ENABLE_CI_AUTOLOGIN: "true"
GLAB_CHECK_UPDATE: "false"
GLAB_SEND_TELEMETRY: "false"
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends jq
- curl -fsSL -o /tmp/glab.deb
"https://gitlab.com/gitlab-org/cli/-/releases/v$GLAB_VERSION/downloads/glab_${GLAB_VERSION}_linux_amd64.deb"
- echo "$GLAB_SHA256 /tmp/glab.deb" | sha256sum -c -
- dpkg -i /tmp/glab.deb
.typemut:
extends: .glab
image: ghcr.io/astral-sh/uv:python3.13-bookworm
timeout: 45m
variables:
RUNNER_SCRIPT_TIMEOUT: 35m # < timeout, > --max-duration (Runner 16.4+)
TYPEMUT_MAX_DURATION: "1500"
script:
- uv sync
- bash ci/typemut-db.sh # the previous database, or none
- uv run typemut run --incremental --jobs 4 --max-duration $TYPEMUT_MAX_DURATION
--baseline typemut-baseline.json $TYPEMUT_EXTRA_ARGS
.typemut-store:
extends: .typemut
resource_group: typemut-db # one job at a time, like GitHub's concurrency group
artifacts:
paths: [typemut.sqlite]
when: always # failed and timed-out runs store their results too
expire_in: 30 days
typemut:
extends: .typemut-store
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
typemut-refresh: # weekly pipeline schedule on the default branch
extends: .typemut-store
timeout: 6h
variables:
RUNNER_SCRIPT_TIMEOUT: 5h50m
TYPEMUT_MAX_DURATION: "20400" # 5h40m: a refresh rechecks only what fits
TYPEMUT_EXTRA_ARGS: "--refresh"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
typemut-mr:
extends: .typemut
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
ci/typemut-db.sh:
#!/usr/bin/env bash
# Downloads typemut.sqlite of the newest typemut run on the default branch.
# Never fails the job: without a database typemut runs every mutant.
# No `set -e` (bash ignores it left of `||`): every step is chained instead.
set -uo pipefail
repo=(--repo "$CI_PROJECT_PATH")
api="projects/$CI_PROJECT_ID"
# Ids of the 50 newest pipelines of the default branch, newest first.
pipelines() {
glab ci list "${repo[@]}" --ref "$CI_DEFAULT_BRANCH" \
--order id --sort desc --per-page 50 --output json | jq '.[].id'
}
# Id of the pipeline's typemut job if it succeeded or failed, else nothing.
typemut_job() {
glab ci get "${repo[@]}" --pipeline-id "$1" --output json | jq 'first(.jobs[]?
| select(.name == ("typemut", "typemut-refresh"))
| select(.status == ("success", "failed"))
| .id) // empty'
}
# Id of the typemut job of the newest pipeline that has one.
latest_typemut_job() {
local ids id job
ids="$(pipelines)" || return
for id in $ids; do
job="$(typemut_job "$id")" || return # an API error is not "no job"
if [ -n "$job" ]; then echo "$job"; return 0; fi
done
return 1
}
# Saves the artifact file at an API path as typemut.sqlite, if it is a database.
download() {
glab api "$1" > typemut.sqlite.part &&
head -c 16 typemut.sqlite.part | grep -q '^SQLite format 3' &&
mv typemut.sqlite.part typemut.sqlite
}
fetch() {
if [ -z "${TYPEMUT_API_TOKEN:-}" ]; then
# The job token cannot list pipelines: latest successful pipeline only.
local ref
ref="$(jq -rn --arg ref "$CI_DEFAULT_BRANCH" '$ref | @uri')" &&
download "$api/jobs/artifacts/$ref/raw/typemut.sqlite?job=typemut" &&
echo "typemut database of the latest successful pipeline"
return
fi
unset GITLAB_ACCESS_TOKEN OAUTH_TOKEN # glab uses typemut's own token only
export GITLAB_TOKEN="$TYPEMUT_API_TOKEN"
local job
job="$(latest_typemut_job)" &&
download "$api/jobs/$job/artifacts/typemut.sqlite" &&
echo "typemut database from job $job"
}
fetch || echo "No typemut database of $CI_DEFAULT_BRANCH: running every mutant"
rm -f typemut.sqlite.part
Works with caveats, so prefer artifacts:
- By default protected branches (the default branch) and unprotected ones
(merge request branches) get separate caches, so a merge request never sees
the cache of the default branch. Turning off Use separate caches for
protected branches fixes that, but then any branch's job can write the
cache the default branch reads, unless every job but the default branch's
uses
policy: pull. fallback_keysneeds GitLab 16.0 or later, a variable incache:policy16.1 or later,$CI_DEFAULT_BRANCH_SLUG(the fallback key must be slugged like the key the default branch saves under) 17.4 or later, andRUNNER_SCRIPT_TIMEOUTGitLab Runner 16.4 or later.- Without a distributed cache (S3, GCS, ...), each runner has its own cache, and a job on another runner starts from nothing.
typemut:
image: ghcr.io/astral-sh/uv:python3.13-bookworm
timeout: 45m
variables:
RUNNER_SCRIPT_TIMEOUT: 35m
cache:
key: typemut-db-$CI_COMMIT_REF_SLUG
fallback_keys: [typemut-db-$CI_DEFAULT_BRANCH_SLUG]
paths: [typemut.sqlite]
when: always
policy: $TYPEMUT_CACHE_POLICY
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
TYPEMUT_CACHE_POLICY: pull-push
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
TYPEMUT_CACHE_POLICY: pull # merge requests only read the cache
script:
- uv sync
- uv run typemut run --incremental --jobs 4 --max-duration 1500 --baseline typemut-baseline.json