Investigate CI failures for a Materialize PR or Buildkite build.
If the input is a Buildkite build URL rather than a PR (a scheduled nightly on
main, a release-qualification build), start directly at "Listing a
build's failed jobs directly", taking <PIPELINE> and <BUILD_NUMBER>
straight from the URL:
https://buildkite.com/materialize/<PIPELINE>/builds/<BUILD_NUMBER>,
then continue from Step 2.
Prerequisites
This skill requires gh (GitHub CLI), authenticated, plus one of two ways
to reach Buildkite: the Buildkite MCP server's tools (preferred, next
section) or bk (Buildkite CLI), authenticated. If any of these turns out
to be missing or unauthenticated, stop immediately and tell the user
what to fix (bk configure or bk auth login for the Buildkite CLI, or the
MCP setup below). Do not attempt to use the REST API directly or any other
workaround.
Buildkite MCP: preferred when available
When the Buildkite MCP server's tools are available in the session (tool
names like get_build_failure_summary, search_logs, ...), prefer them
over bk everywhere below: structured JSON with the
job-state semantics already applied, and no log cleanup needed. The server documents its
own tools; this section adds only what it does not know:
org_slugismaterialize.get_build_failure_summaryreplaces "Listing a build's failed jobs directly", Step 2, and often Step 3;search_logs/read_logs/tail_logsreplace thebk job logpipelines; the artifact and annotation tools replace the correspondingbkcommands. Everything Materialize-specific (annotation contents,bin/ci-failures,bin/ci-shards, Steps 4-5) applies unchanged.- The summary's job list is bounded by the response's
job_limit(10 on the hosted server, where a largermax_jobsis silently clamped), with terminal problem jobs sorted before downstream broken ones. Onjobs_truncated: true, get the definitive failed-job list fromlist_jobswithstate: "failed,timed_out"andinclude_retried_jobs: false. That last parameter is what preserves triage semantics: the server defaults it to true, which counts every failed attempt of a retried job separately, including jobs that passed on retry. The summary bounds annotation content too: oncontent_truncated: true, fetch the full set withlist_annotations.
If the MCP is absent, read bk-fallback.md in this skill's directory for
the bk command recipes and continue with those. At the end, mention to
the user that they can set the server up (hosted read-only endpoint:
claude mcp add --scope user --transport http buildkite https://mcp.buildkite.com/mcp/readonly;
it saves tool calls but costs somewhat more tokens).
Search existing failures by pattern
Search for already recorded failures in CI with a short, stable error
substring. The output is JSON whose content blobs are huge, so project it
down, and read the total from .meta.totalRowCount, which ignores the row
cap and is the "how chronic is this?" number:
bin/ci-failures 'foobar' 2>/dev/null | jq -r '.meta.totalRowCount,
(.data[] | [.build_date[0:10], .build_identifier, .test_suite, .issue] | @tsv)'
The positional pattern matches failure content. --search <text> replaces
the positional pattern and matches across build, test, issue, content, and
branch at once. Narrow results with --branch main, --version, --issue,
--test (the Buildkite job name), --build, --start-date, or
--end-date; --size raises the row cap (default 31, max 100).
Step 1: Find the build
Parse $ARGUMENTS to get the PR number (a plain number or a
https://github.com/MaterializeInc/materialize/pull/<PR_NUMBER> URL), then
list the failing checks:
gh pr checks <PR_NUMBER> 2>&1
Failing rows link to
https://buildkite.com/materialize/<PIPELINE>/builds/<BUILD_NUMBER>#<JOB_ID>,
the identifiers used throughout this skill.
Listing a build's failed jobs directly
Buildkite mirrors each job's result into GitHub as a
buildkite/<pipeline>/<job> commit status (never-dispatched jobs post
nothing), but gh pr checks reads the PR's current head commit only. A build
therefore shows up there only if it ran on exactly that commit. For everything
else (scheduled nightlies on main, release-qualification builds, a PR nightly
from before the latest push), list the genuinely failed jobs via the API:
list_jobs as described in the MCP section, or with bk the listing recipe
in bk-fallback.md. Either way, also grab the build's commit and branch:
Step 5's known-vs-new checks need the commit, and neighboring-build lookups
need the branch.
Job-state semantics matter here:
- The failure states are
failedandtimed_out, the same set asBUILDKITE_RELEVANT_FAILED_BUILD_STEP_STATESinmisc/python/materialize/buildkite_insights/buildkite_api/buildkite_constants.py. brokenis not a failure. Buildkite marks a jobbrokenwhen its configuration prevents it from running, for example a branch filter or anif:condition that evaluates false, so the job never dispatches. Every build contains broken jobs (a routine main nightly has dozens), so counting them massively over-counts failures.- Jobs with
soft_failed: trueare allowed to fail without making the build red; no current CI step sets it, so filtering it is defensive. waiting_failedis not a failure either: the job's dependency (usually a build step) failed, so it never started. Its presence is informative, though. A PR build full ofwaiting_failedjobs ran no tests at all, so report that fixing the visible failures is necessary but no proof the build then goes green.- Retried jobs (automatic or manual) appear only as their latest attempt, so a job that failed and then passed on retry counts as passed. That is the right verdict for build triage; fetch the earlier attempts only when an in-depth investigation needs them. A manual retry that failed again is itself a signal: someone already tried to shake the failure off and it reproduced.
To find a PR's builds that are not on its current head in the first place (an
older nightly, a pre-push run), get the branch with
gh pr view <PR_NUMBER> --json headRefName and filter Buildkite builds by
branch; the MCP's list_builds takes a branch: filter, and fork branches
are named <owner>:<branch> on Buildkite.
Step 2: Check annotations first
Before diving into logs, fetch the build annotations. They contain pre-extracted error messages, stack traces, and links to known flaky test issues — this saves significant time compared to grepping through raw logs. With the MCP they arrive in get_build_failure_summary (or list_annotations); with bk, use the annotations recipe in bk-fallback.md.
Besides the error itself, an error annotation (style "error") carries
known-issue links (Linear keys like CPU-170, or legacy
database-issues/#NNNN) and the job's main-branch history, a flaky-test
indicator.
Things to know when reconciling annotations against jobs:
- Error annotations persist from failed attempts even when a retry later
passed, so a build can have more error annotations than failed jobs. A
bin/ci-annotate-errorsannotation'scontextfield is<JOB_ID>-error, and that job id can belong to a retried attempt that the default job listing does not contain. (Other producers use other contexts, e.g.images-not-publicfrom the build step.) - Only jobs running through the mzcompose/cloudtest plugins get annotations
and
bin/ci-failuresrows (both come frombin/ci-annotate-errors). Plain shell steps (lint, Security advisories, and similar checks) produce neither, so a real failure there has no annotation at all. Go straight to its job log. - Info-style annotations (" succeeded with known error logs") mean the
job passed and every logged error matched an open known issue; they are
not failures. When a job went red via "Test succeeded, but unknown errors
found in logs" (see Step 3), the unknown and potential-regression entries
in its annotation are what turned it red. The reverse also exists: a
failed job turns green (" would have failed with known error logs")
when all its logged errors match issues marked
ci-ignore-failure: true.
Step 3: Fetch logs when needed
Only fetch full logs when annotations don't provide enough detail. On PR test builds, read compile and lint job logs first (clippy, lint-and-rustfmt): they often explain every downstream failure.
Fetch and search logs with the MCP's search_logs/read_logs/tail_logs,
or with bk use the job-log recipes in bk-fallback.md. The log tail can be a
red herring: some jobs print long non-error output after the actual error
(cargo deny's dependency tree runs hundreds of lines past the advisory), so
when the tail shows no error, search or grep for the error instead of
tailing more.
NOTE: for mzcompose-based jobs (testdrive, SQLsmith/SQLancer, platform checks,
...) the job console log holds only the harness's output. The services' own
output (environmentd/clusterd panics and errors) goes to services.log and
similar files inside the job's log artifacts. A panic that is absent from
the job's console log can still be the failure, so never conclude "this
error did not happen in this run" from it alone. Check the annotations or
bin/ci-failures instead, both are fed by bin/ci-annotate-errors, which
scans the uploaded log artifacts (services.log, run.log, junit XML, ...)
at the end of each job. The same scan can fail a job whose own workflow
passed: the console log then reads Test succeeded, but unknown errors found in logs, marking as failed, and the cause is whatever the scan found in the
artifacts, not a test failure. Alternatively, download the log artifacts and
grep them directly.
Artifacts
Jobs upload artifacts (junit XML, service logs, coredumps, ...). Use the
MCP's list_artifacts_for_build/list_artifacts_for_job/get_artifact,
or with bk use the artifacts recipes in bk-fallback.md.
Shard contents
Sharded jobs (SLT, testdrive, platform checks, feature benchmark, ...) record
the workflows/files/scenarios they ran in build meta-data. bin/ci-shards
shows it:
# Mapping of every sharded job to what it ran, with a link per job whose
# `#` fragment is the `<JOB_ID>` for fetching job logs (omit links with --no-url)
bin/ci-shards https://buildkite.com/materialize/<PIPELINE>/builds/<BUILD_NUMBER>
# Which job(s) ran a specific item (test file, scenario, or workflow,
# `workflow_foo_bar` function names are matched as `foo-bar`)
bin/ci-shards https://buildkite.com/materialize/<PIPELINE>/builds/<BUILD_NUMBER> numeric.td
Step 4: Categorize failures
Use these Materialize-specific patterns to diagnose:
Clippy errors
Code lint issues in changed files. Fix the code, not the lint config.
check-test-flags lint failure
A new configuration flag was introduced but not registered in the required places:
misc/python/materialize/parallel_workload/action.py(FlipFlagsAction)misc/python/materialize/mzcompose/__init__.py(get_variable_system_parameters / get_minimal_system_parameters / UNINTERESTING_SYSTEM_PARAMETERS)
Testdrive cascades
After one test crashes environmentd, all subsequent tests in that shard fail with Name or service not known or connection closed. Only the first failure in a shard matters — everything after it is a cascade. Look for the first error: or FAIL in the log.
Testdrive shards with the same number (e.g., testdrive-10 and testdrive-with-alloydb-10) run the same tests — if both fail, it's likely to be the same root cause.
SLT failures
Check whether it's wrong output (behavioral change) vs. connection error (crash/timeout). Wrong output means the query semantics changed. An InconsistentViewOutcome failure comes from CI's --auto-index-selects mode; the mz-test skill explains the mechanism and the exemption lists.
Timeouts
A timed_out job hit its step budget (timeout_in_minutes in
ci/<pipeline>/pipeline.template.yml). The annotation records only the fact
("test timed out"), never the cause. Find the cause in the console log: the
last completed unit of work, plus anything marked as still running when the
job was canceled (nextest prints SLOW [>1200.000s] lines naming the stuck
tests). If no such marker appears, compare wall-clock against the job's last
passing run to tell a gradually outgrown budget from a hung test.
Rarer classes
If the failure matches one of these, read rare-failures.md in this
skill's directory for its triage pattern: New regression against <version> (feature benchmark), a cargo-fuzz crash, a Miri failure, a
limits-test failure, or ImagesNotPublicError.
Step 5: Summarize
Group failures by root cause, not by job name — typically many failing jobs share just 1-2 root causes. Per root cause, report a description, the affected jobs, and what to fix.
Distinguish between issues that are clearly caused by the change under test vs. pre-existing flaky tests. The annotations often link to known flaky test issues — use these to identify pre-existing flakes vs. new regressions.
More ways to establish known vs new:
- Check whether main already fixed it. Any build can run code that is behind
main: a nightly investigated the morning after, but also a fresh PR based
on an older main. From an up-to-date main checkout run
git log <BUILD_COMMIT>..HEAD -- <suspect-file>orgit log -S '<error token>'. A fix can also sit in a not-yet-merged PR, invisible to git log: look at recently opened PRs for one that already addresses the failure (a PR's file list shows which of the build's root causes it covers). When citing a later build as evidence of a fix, confirm the specific job's state there ispassed: a build can be green because the job wasbrokenand never ran. - Known-issue tracking lives in Linear. Annotations and
bin/ci-failurescite issue keys likeCPU-170orSS-361, which resolve tohttps://linear.app/materializeinc/issue/<KEY>. Legacy references point at GitHubMaterializeInc/database-issuesinstead. - Annotations and
bin/ci-failuresclassify a failure three ways; for the latter two, check the signature's history withbin/ci-failuresbefore treating the failure as new. A known-issue link means it matched an open tracked issue."UNKNOWN ERROR"means it matched no tracked issue, not that the failure is new: a long streak under that label is a chronic untracked flake worth filing. An issue reference tagged(POTENTIAL REGRESSION)means it matched a tracked issue that is now closed: a known signature with stale tracking, not by itself evidence of a new regression; suggest to the user that the closed issue may need reopening. The issue matching is a content heuristic and often wrong, so verify that the matched issue actually describes the problem: search the tracker for the error text, since the right issue may already exist open, or the problem may need a totally new one.