# GitHub Actions Matrix must define at least one vector, and its siblings

> GitHub Actions Matrix must define at least one vector is one of five messages the matrix converter can raise. See which block each one is reading.

Source: https://latchkey.dev/learn/github-actions/gha-matrix-invalid-configuration  
Updated: 2026-09-20

GitHub Actions Matrix must define at least one vector is raised when a strategy has a matrix block that produced neither a dimension nor a usable include list. It is one of five distinct messages the matrix converter can record, and each of the five names a different part of the block, which makes the message itself the fastest diagnosis you will get.

## What this error means

The workflow is annotated and no run starts. The annotation quotes a sentence about the matrix rather than about YAML, so the file parsed correctly and was rejected one layer later, while the strategy was being turned into a list of job configurations. Frequently the matrix in question was built from an expression, worked yesterday, and broke when the data behind the expression came back empty.

```Message text from WorkflowTemplateConverter in actions/runner
Matrix vector 'node' does not contain any values
```

## Common causes

### A dimension resolved to an empty list

The commonest one by a distance, and nearly always an expression. A job produced `[]`, or a filtered list came back with nothing in it, and the converter reports the dimension by name. The workflow file did not change.

### The matrix has only an include or only an exclude block

Exclude contributes no dimension, and an empty include contributes nothing either, so the walk ends having seen no vector. The block looks substantial in the file and produces nothing, which is why the message reads as though the matrix is missing.

### A dimension was written as a scalar instead of a list

`node: 20` rather than `node: [20]`. The schema requires a sequence for a loose matrix key, so this is caught while the file is read rather than by the converter, and the message names the value instead of the dimension.

### An exclude key does not name a dimension

A misspelling, or an exclude entry written against a key that only exists inside an include entry. Exclude validates its keys, quotes the offending one, and fails. In our experience this appears after someone moves a dimension into include and leaves the exclude entries behind.

## How to fix it

### Read which part of the block the message names

1. If the message quotes a dimension name, that dimension's list is empty.
2. If it names the matrix as a whole, the walk found no dimension and no usable include entry.
3. If it names include or exclude, one entry in that list has no keys.
4. If it quotes a key and says it matches nothing, an exclude key is not a declared dimension.

### Make the producing job refuse to publish an empty list

For a generated matrix, put the check where the data is decided. A job that exits with a message when its list is empty fails at the point of the real problem and stops the matrix failing with a message about a dimension the author never wrote.

```.github/workflows/ci.yml (illustrative)
- run: |
    [ "$(jq length ci/targets.json)" -gt 0 ] || { echo "targets.json is empty" >&2; exit 1; }
```

### Give the matrix at least one dimension before filtering it

Exclude removes from a product and include augments or appends, so neither is a substitute for declaring a dimension. If the job list is meant to come entirely from include entries, that is legal, but the include list has to be non-empty at the moment the converter reads it.

```.github/workflows/ci.yml (illustrative)
strategy:
  matrix:
    include:
      - target: linux
      - target: macos
```

### Guard the whole job rather than the matrix

When an empty list is a legitimate outcome, do not try to express that as an empty matrix. Put a condition on the job that skips it when the upstream output is empty, which produces a skipped job rather than an invalid workflow and leaves the run readable.

```.github/workflows/ci.yml (illustrative)
build:
  needs: plan
  if: needs.plan.outputs.targets != '[]'
  strategy:
    matrix:
      target: ${{ fromJSON(needs.plan.outputs.targets) }}
```

## How to prevent it

- Assert that a generated matrix list is non-empty in the job that generates it.
- Keep a literal dimension in the matrix whenever the job must always run at least once.
- Skip the job with a condition when an empty list is a legitimate outcome.
- Update exclude entries in the same change that renames or moves a dimension.

## Five messages, five branches

The converter walks the strategy block key by key. `include` and `exclude` have their own branches; anything else is treated as a cross product dimension. For a dimension it asserts that the value is a sequence and then checks whether the sequence is empty, and an empty one is recorded by name. For include it records whether the list had any entries at all, and for exclude it validates every key against the declared dimensions.

At the end of the walk it asks one question: did we see either a cross product dimension or a usable include list? If neither, it records that the matrix must define at least one vector. That is the message in this page's title, and its meaning is narrower than it reads: not that your matrix is wrong, but that after the walk there was nothing to build a product from.

The other three come from the include and exclude handlers, which are strict in a way the dimension handler is not. An include entry with no keys at all is rejected. An exclude entry with no keys is rejected. An exclude key that does not name a declared dimension is rejected and quoted. Include, notably, does not validate its keys, which is the subject of its own page in this batch.

| Message | Part of the block it is reading | Shape that produces it |
| --- | --- | --- |
| Matrix must define at least one vector | the block as a whole, after the walk | a matrix with only `include` that is empty, or only `exclude` |
| Matrix vector 'x' does not contain any values | one dimension | `x: []`, or an expression that produced an empty list |
| Matrix include mapping does not contain any values | one include entry | an entry with no keys |
| Matrix exclude filter must not be empty | one exclude entry | an entry with no keys |
| Matrix exclude key 'x' does not match any key within the matrix | one exclude key | a key that is not a declared dimension |

> An expression anywhere in the strategy changes the timing rather than the rules. While the file is validated early, a dimension whose value is still an expression is skipped rather than judged, so an empty list only produces its message once the expression has resolved.

## Why an expression-driven matrix breaks later, not on push

Each of these branches begins by checking whether the value it is about to read still contains an expression token. During early validation it steps over anything that does, because the contexts needed to evaluate it do not exist yet. Nothing about your file is wrong at that moment and nothing can be concluded.

That produces a failure mode worth recognizing. A matrix fed by `fromJSON(needs.plan.outputs.targets)` validates on every push and fails on the day the upstream job emits an empty array, at which point the converter finally sees a sequence with no entries and records that the vector contains no values. The change that broke it is in a script, or in the data the script read, not in the workflow.

Because of that, the durable fix for a generated matrix is to make the producing job assert its own output rather than to defend the consumer. A job that refuses to publish an empty list gives you a failure with a message you wrote, at the place where the data was decided.

```.github/workflows/ci.yml (illustrative)
- id: plan
  run: |
    targets=$(jq -c . ci/targets.json)
    if [ "$targets" = "[]" ]; then
      echo "no targets to build" >&2
      exit 1
    fi
    echo "targets=$targets" >> "$GITHUB_OUTPUT"
```

## Exclude checks its keys, include does not

This asymmetry explains why some matrix mistakes fail loudly and others quietly change the job list. Exclude validates every first-level key against the dimensions and quotes any key that is not one of them, so a typo there is an error you can read. Include sorts its keys instead: a key that names a dimension becomes part of a filter, and any other key becomes extra data attached to whatever the filter matched.

So an exclude entry with `so:` where you meant `os:` fails the workflow, and an include entry with the same typo silently adds a value called `so` to your jobs. If you are debugging a matrix that produces the wrong number of jobs rather than an error, you are on the include side, and the include page in this batch traces that rule to the builder that applies it.

One more consequence follows from the same asymmetry. A matrix consisting only of an `exclude` block cannot define anything, because exclude never contributes a dimension, and that is one of the ways to reach the must-define message with a block that looks populated.

## Why there is no recorded run on this page

All five messages come from the converter that turns a strategy into job configurations, which runs before any job exists, so there is no runner and no log to capture. Beyond that, the value of this page is the discrimination between five messages, and four of the five cannot coexist in one workflow file: a matrix that has no dimension at all cannot also have a dimension that is empty, and an exclude entry that is empty has no key to be invalid. A single recording could show at most one of the five, which is the one the reader already has in front of them.

The five strings and the conditions that raise them sit within a few dozen lines of one another in the published converter and matrix builder, so the mapping in the table above is read rather than inferred.

## FAQ

### What does "Matrix must define at least one vector" actually mean?

That after walking the matrix block, the converter found neither a cross product dimension nor a non-empty include list. It is a statement about the outcome of the walk rather than about any one key, which is why it can fire on a block that has plenty of content, for example one containing only `exclude`.

### Why did my matrix fail today when the file has not changed?

Almost certainly because a dimension is built from an expression and the data behind it came back empty. Expressions are skipped during early validation, so the converter only sees the empty sequence once the expression resolves, and it then names the dimension in the message.

### Why does exclude reject an unknown key when include does not?

Because they do different jobs. Exclude validates every first-level key against the declared dimensions and quotes any key that is not one. Include sorts its keys instead, treating a known name as a filter and any other name as extra data, so a typo there becomes a value rather than an error.

### Can a matrix be built entirely from include entries?

Yes. A non-empty include list satisfies the check that the matrix defined something, and each entry becomes its own configuration because there is no cross product for its filter to match. What is not allowed is a matrix whose include list is empty, or one made only of exclude entries.

## References

- [actions/runner: WorkflowTemplateConverter.cs, the strategy walk](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs)
- [actions/runner: MatrixBuilder.cs, include and exclude validation](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs)
- [GitHub Actions: workflow syntax, jobs.<job_id>.strategy.matrix](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Actions: run variations of a job with a matrix](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
