# GitHub Actions matrix include extra job, and the rule that adds it

> A GitHub Actions matrix include extra job is the rule working, not a bug. See how the builder reads each include key and which side yours fell on.

Source: https://latchkey.dev/learn/github-actions/github-actions-matrix-include-unexpected-combination  
Updated: 2026-09-20

A GitHub Actions matrix include extra job appears because every entry under `include` is read as two things at once: the keys that name an existing matrix dimension act as a filter, and every other key is extra data. An entry whose filter matches nothing in the cross product is not discarded, it becomes a job of its own.

## What this error means

The run starts more jobs than the arithmetic says it should, or a value you added under `include` is present on one leg and absent on the others, or it is present on every leg when you meant one. Nothing is annotated and nothing is red, so there is no message to search for. The tell is in the job list itself: the jobs you expected carry the names they always carried, and the surplus job carries a longer name made of values you recognize from the include entry.

```Illustrative workflow fragment, not a log line
strategy:
  matrix:
    node: [18, 20]
    include:
      - node: 22
        experimental: true
# three jobs, not two
```

## Common causes

### The include entry names a dimension value that does not exist

The single commonest one, and the one that produces the surplus job. `node: 22` where the dimension is `[18, 20]` compiles to a filter that no combination satisfies, so the entry is appended as its own configuration with `node: 22` plus whatever else it carried.

### The include entry has no keys that name a dimension

An entry made only of new key names has an empty filter, and an empty filter matches everything. The extras land on every combination, which is the correct behavior for a shared default and a nasty surprise when you meant to flag one leg.

### A dimension name is misspelled in the include entry

Because an unrecognized key is treated as extra data rather than as an error, `os: ubuntu-latest` written as `so: ubuntu-latest` does not fail. It becomes an extra called `so`, the filter is empty, and every job gains a value nobody will ever read.

### Two include entries both match the same combination

When several entries match, their extras are merged into one bag and a later entry's value replaces an earlier one under the same key. In our experience this shows up in long matrices where two entries overlap by accident rather than by design.

## How to fix it

### Decide, for each entry, whether you are filtering or adding

1. List the dimension names declared directly under `matrix`.
2. For each include entry, mark every key that appears in that list. Those are the filter.
3. Check that the filter's values actually occur in the dimension. If any value does not, that entry will become a new job.
4. Everything unmarked is extra data and will be merged into whatever the filter matched.

### Add a job on purpose by giving the entry a complete set of values

If you want an extra leg, write the entry so it names every dimension with a value outside the declared list, and treat the resulting job as a first-class combination rather than a variation. This is the documented way to add a configuration, and being explicit about it means the next reader does not have to work out which side of the rule the entry fell on.

```.github/workflows/ci.yml (illustrative)
strategy:
  matrix:
    node: [18, 20]
    include:
      - node: 22
        experimental: true

steps:
  - run: echo "node ${{ matrix.node }} experimental=${{ matrix.experimental }}"
```

### Read the extra value rather than looking for it in the job name

An augmented job keeps its old label, so confirm the value through the matrix context inside the job instead of trusting the run page. A single echo of the key you added tells you immediately whether the entry matched.

```.github/workflows/ci.yml (illustrative)
- name: Show matrix
  run: echo "${{ toJSON(matrix) }}"
```

### Use exclude when you want fewer combinations

Exclude validates its keys against the declared dimensions and rejects a key that is not one of them, so a typo fails the workflow instead of silently changing the job list. Shrinking the matrix with exclude and growing it with include keeps each tool doing the thing it checks.

## How to prevent it

- Keep include entries short, so the filter and the extras are visible at a glance.
- Never let an include entry be the only place a dimension value appears.
- Print the matrix context once in a new matrix job, then delete the step.
- Prefer exclude for removals, because it validates its keys and include does not.

## The rule, as the builder applies it

The published matrix builder in actions/runner does the sorting in the constructor of its include handler. For each entry it walks the keys, and a key that names one of the declared matrix dimensions goes into a filter while every other key goes into a bag of extras. That single test, whether the key is already a dimension, decides everything that follows.

Each filter is then compiled into one equality expression per key, of the form `matrix['node'] == 22`, and evaluated against every combination the cross product produced. A combination that satisfies all of an entry's filter expressions receives that entry's extras. After the cross product has been walked, any entry whose filter matched no combination at all is turned into a fresh combination made of its filter keys plus its extras, and appended.

The documentation states the same outcome from the other side: the pairs are added to each combination if none of them overwrite an original matrix value, and if the object cannot be added to any combination a new one is created instead. Both descriptions are true. The code version is the one that tells you where to look, because it names the test.

| Include entry against `node: [18, 20]` | Filter keys | What the builder does |
| --- | --- | --- |
| `- node: 18`, `coverage: true` | `node` | adds `coverage` to the node 18 job only |
| `- node: 22`, `experimental: true` | `node` | matches nothing, appends a third job |
| `- coverage: true` | none | adds `coverage` to every job |
| `- node: 18` | `node` | matches, but carries no extras, so nothing changes |

> An entry with neither a filter key nor an extra key is rejected outright: the converter records "Matrix include mapping does not contain any values" against that entry.

## Why an added job has a longer name than an augmented one

This is the most reliable way to tell the two outcomes apart from the run page alone, and it falls out of the order of two lines in the builder. When a configuration is created, the identifier and the display name are assembled by walking the combination that exists at that moment, and only after the name has been built are the extras merged in.

For a combination that came out of the cross product, the extras arrive too late to appear in the name. So a job that was augmented by an include entry looks exactly as it did before you added the entry, which is why people conclude the entry did nothing. The values are there; they are readable through the matrix context inside the job and absent from the label on the outside.

For an entry that matched nothing, the new combination is built from the filter keys and the extras together before it reaches the naming step, so every value in the entry ends up in the name. That is the surplus job with the unfamiliar label.

## Exclude has different rules, and says so out loud

It is worth knowing the asymmetry, because people reach for `exclude` after `include` surprises them. An exclude entry is validated key by key against the declared dimensions, and a key that is not a dimension is rejected with a message naming it: the converter records that the exclude key does not match any key within the matrix. An empty exclude entry is rejected too.

So exclude cannot quietly invent a dimension the way include can. Every exclude key has to be a real one, and the entry is a partial match against the cross product that removes whatever it matches. If you want to delete a combination, exclude is the precise tool. If you want to add one, include is, and the price of that power is that a typo in a dimension name turns into a new job instead of an error.

```.github/workflows/ci.yml (illustrative)
strategy:
  matrix:
    node: [18, 20]
    os: [ubuntu-latest, windows-latest]
    exclude:
      - node: 18
        os: windows-latest
    include:
      - node: 20
        os: ubuntu-latest
        coverage: true
```

## Why there is no recorded run on this page

The evidence this page needs is the shape of a job list, not the content of any job. A recorded Latchkey run would give you one leg's log, and one leg's log cannot show you how many legs the expansion produced or which of them received an extra value. The artifact that would settle it is a screenshot of a run summary, which is a picture of a list rather than a measurement.

The expansion itself is deterministic and open. Every claim above comes from the builder that performs it, including the ordering detail that decides whether a value shows up in the job name, and that ordering is two adjacent statements in one method rather than something a run reveals.

## FAQ

### Why did adding an include entry create an extra job?

Because the entry's filter matched none of the combinations in the cross product. The matrix builder collects every include key that names a declared dimension into a filter, evaluates it against each combination, and appends any entry that matched nothing as a new configuration. A value outside the declared list guarantees that outcome.

### Why is my include value missing from the job name?

It is not missing from the job, only from the label. The identifier and display name are built from the combination as it stands, and the include entry's extra values are merged in afterwards. Read the value through the matrix context inside the job and you will find it.

### How do I apply one include value to every matrix combination?

Write an entry whose keys name no declared dimension. That gives the entry an empty filter, and an empty filter matches every combination, so the values are merged everywhere. This is also why an accidental typo in a dimension name spreads a value across the whole matrix.

### Does include or exclude run first?

Exclude is applied first. The builder walks the cross product, drops any combination that matches an exclude filter, and only then offers the survivors to the include filters. A combination you excluded cannot be augmented by an include entry, though an include entry that matches nothing will still be appended as its own job.

## References

- [GitHub Actions: workflow syntax, matrix include and exclude](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [actions/runner: MatrixBuilder.cs, filter and extra classification](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs)
- [actions/runner: WorkflowTemplateConverter.cs, strategy conversion](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs)
- [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
