{"title":"Preview a GitHub Actions Matrix Before include Adds the Wrong Jobs","excerpt":"Run a Playwright matrix through a browser-only preview of strategy.matrix: one include entry extends two jobs, one changes existing jobs instead of adding a canary, and one creates a job whose empty --shard runs the whole suite. The rule behind each, the fix, and how GitHub names the jobs.","canonicalUrl":"https://automationtester.in/blog/tools-reviews/preview-github-actions-matrix-include-exclude","category":{"name":"Tools & Reviews","slug":"tools-reviews"},"tags":["github-actions","ci-cd","playwright","yaml","test-sharding"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-11-06T05:30:00.000Z","dateModified":"2026-11-06T05:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/preview-github-actions-matrix-include-exclude.webp","alt":"A three-by-three grid with two dashed, excluded cells. One orange line forks into both pills of a cell; another runs under the grid to a new orange cell with a hollow pill. A green arrow leads to the expanded job list."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Paste the workflow into the <a href=\"https://automationtester.in/tools/github-actions-matrix-preview\">GitHub Actions Matrix Preview</a> and read the job list before you push. GitHub builds the product of the matrix variables, removes every combination an <code>exclude</code> entry partially matches, and only then applies <code>include</code>. An include entry is merged into every remaining original combination it can join without changing one of that combination's matrix values; if it can join none, it becomes a job of its own. The preview shows which happened for each entry, the job names, and the count against the 256-job limit.</div>\n\n<p>A WebKit job on Windows takes nine minutes while every other browser job takes four. Its log shows the whole Playwright suite running in one go. Nobody removed the shard from the command; the job simply has no shard value, because the include entry that created it never set one, and <code>--shard=</code> with nothing after it is not an error. Two other jobs upload a coverage report that was meant to come from one. All of this was decided by the matrix before a runner started, and all of it can be seen without running the workflow.</p>\n\n<h2 id=\"matrix-sample-workflow\">A Playwright matrix with three include entries</h2>\n\n<p>Three operating systems, three browsers and two shards, with two exclusions and three include entries, each with a comment saying what its author wanted:</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">jobs:\n  e2e:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      max-parallel: 6\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n        browser: [chromium, firefox, webkit]\n        shard: [1/2, 2/2]\n        exclude:\n          - os: windows-latest\n            browser: webkit\n          - os: macos-latest\n            browser: firefox\n        include:\n          # Collect coverage on one job\n          - os: ubuntu-latest\n            browser: chromium\n            coverage: true\n          # Add a Node 24 canary job\n          - os: ubuntu-latest\n            browser: chromium\n            node: 24\n          # Run WebKit on Windows once after all\n          - os: windows-latest\n            browser: webkit\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-node@v7\n        with:\n          node-version: ${{ matrix.node || 22 }}\n      - run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}</code></pre>\n\n<p>This is the preview's built-in sample, so <strong>Load sample</strong> reproduces it. The summary reads <strong>Jobs 15 / 256</strong>, <strong>Product 18</strong>, <strong>Excluded 4</strong>, <strong>Added by include 1</strong>, <strong>fail-fast false</strong> and <strong>max-parallel 6</strong>. The order panel and the rows that matter:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">exclude[0] {\"os\":\"windows-latest\",\"browser\":\"webkit\"}   removed 2 combinations\nexclude[1] {\"os\":\"macos-latest\",\"browser\":\"firefox\"}    removed 2 combinations\ninclude[0] {\"os\":\"ubuntu-latest\",\"browser\":\"chromium\",\"coverage\":true}  merged into 2 jobs\ninclude[1] {\"os\":\"ubuntu-latest\",\"browser\":\"chromium\",\"node\":24}        merged into 2 jobs\ninclude[2] {\"os\":\"windows-latest\",\"browser\":\"webkit\"}   added as a new job\n\n#   Job name                              shard  coverage  node  From\n1   e2e (ubuntu-latest, chromium, 1/2)    1/2    true      24    product, extended by include[0], include[1]\n2   e2e (ubuntu-latest, chromium, 2/2)    2/2    true      24    product, extended by include[0], include[1]\n15  e2e (windows-latest, webkit)          -      -         -     added by include[2]</code></pre>\n\n<p>None of the three comments describes what its entry does. Coverage runs on two jobs. No canary job exists; the two existing Ubuntu Chromium jobs quietly moved to Node 24. And the Windows WebKit job has no shard.</p>\n\n<h2 id=\"matrix-include-rules\">The rule that decides every include entry</h2>\n\n<p>GitHub's workflow syntax reference states it in two sentences. The key and value pairs in an include object are added to each matrix combination \"if none of the key:value pairs overwrite any of the original matrix values\", and an object that cannot be added to any combination becomes a new combination. Original values are protected; values an earlier include added are not. The how-to page adds the detail that catches people twice: include entries are compared only with the original combinations, so a later entry is never merged into a job an earlier entry created.</p>\n\n<p>Read the three entries against that rule.</p>\n\n<p><strong>include[0]</strong> names <code>os</code> and <code>browser</code> but not <code>shard</code>. An entry that leaves a variable out does not conflict with any value of it, so both shards of Ubuntu Chromium qualify and both get <code>coverage: true</code>. The preview's note says exactly that: the entry \"is merged into 2 combinations, because it does not name shard\".</p>\n\n<p><strong>include[1]</strong> has the same problem with a worse result. <code>node</code> is not a matrix variable, so setting it can never overwrite an original value, and the entry joins the same two jobs. An include only creates a job when it conflicts with every remaining combination. This entry conflicts with none, so the canary was never a separate job, and the jobs that were supposed to stay on Node 22 now install 24.</p>\n\n<p><strong>include[2]</strong> would have joined the two Windows WebKit combinations, but exclude removed them first. With nothing left to join, it becomes a new combination containing only <code>os</code> and <code>browser</code>. The syntax reference documents this ordering deliberately: \"All include combinations are processed after exclude\", which is how you add back something exclude removed. What it does not do is copy the variables the entry left out.</p>\n\n<h2 id=\"matrix-empty-shard\">What an unset matrix value turns into</h2>\n\n<p>The new job has no <code>shard</code> key. GitHub's contexts reference says that dereferencing a property that does not exist \"will evaluate to an empty string\", so the run step becomes <code>npx playwright test --project=webkit --shard=</code>. In Playwright 1.62.1, <code>resolveShardOption</code> in <code>packages/playwright/src/cli/testActions.ts</code> starts with <code>if (!shard) return undefined</code>: an empty value means no sharding, with no warning. Listing this site's own Playwright suite shows the effect:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">npx playwright test --list --shard=1/2   # Total: 413 tests in 12 files\nnpx playwright test --list --shard=      # Total: 825 tests in 12 files\nnpx playwright test --list               # Total: 825 tests in 12 files</code></pre>\n\n<p>A value Playwright rejects, such as <code>--shard=all</code>, would at least fail the job. The empty string passes, which is why the preview reports a created job that lacks a matrix variable as a warning rather than a note: \"It has no shard, so matrix.shard is an empty string in this job.\"</p>\n\n<h2 id=\"matrix-rewritten\">Entries that each change one thing</h2>\n\n<p>Each fix comes from the same rule. To target one job, name every matrix variable. To force a new job, give it a value of a matrix variable that no remaining combination has.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">        include:\n          - os: ubuntu-latest\n            browser: chromium\n            shard: 1/2\n            coverage: true\n          - os: ubuntu-latest\n            browser: chromium\n            shard: 1/1\n            node: 24\n          - os: windows-latest\n            browser: webkit\n            shard: 1/1</code></pre>\n\n<p>The preview now reports 16 jobs. include[0] is merged into job 1 only. include[1] becomes job 15, <code>e2e (ubuntu-latest, chromium, 1/1, 24)</code>, because \"No original combination has shard: 1/1 (the matrix has 1/2, 2/2), and original values are never overwritten.\" include[2] becomes job 16, <code>e2e (windows-latest, webkit, 1/1)</code>, with a note that it matches an exclude entry and runs anyway. Playwright accepts <code>1/1</code>: the same validation only requires the current shard to be between 1 and the total, and <code>--list --shard=1/1</code> lists all 825 tests.</p>\n\n<p>The <code>shard: 1/1</code> trick works here because shard already exists. When no variable has a spare value, the documented pattern is a single-value variable such as <code>experimental: [false]</code>, with the extra job's entry setting <code>experimental: true</code>. The cost is that the value appears in every job's name, so <code>, false</code> is appended to each original job.</p>\n\n<h2 id=\"matrix-job-names\">How GitHub names matrix jobs</h2>\n\n<p>The job names come from GitHub's behaviour rather than its documentation, which does not describe them. The preview's rules were taken from the names GitHub assigned in public workflow runs on 1 October 2026, read from the REST API's list-jobs endpoint and checked against each run's workflow file:</p>\n\n<ul>\n<li>A job with no <code>name</code>, or a <code>name</code> without an expression, gets the original matrix values in brackets, in matrix order: <code>Unit tests (macos-latest, 3.10)</code> in a pyvisa run.</li>\n<li>Keys an include added to an original combination are not shown. A nats.go run extended its Go 1.27 job with <code>coverage: true</code> and the job is still called <code>test (1.27)</code>.</li>\n<li>A combination an include created lists that entry's values in the order the entry wrote them. A commons-lang entry written as <code>java</code>, <code>experimental</code>, <code>os</code> produced <code>build (27-ea, true, ubuntu-latest)</code>, while the original jobs read <code>build (ubuntu-latest, 8, false)</code>.</li>\n<li>Empty strings are left out: a rhai job whose <code>flags</code> value was <code>\"\"</code> is <code>Build (ubuntu-latest, stable, false)</code>.</li>\n<li>A <code>name</code> containing an expression is used as written with nothing appended.</li>\n</ul>\n\n<p>Two consequences are worth checking for. Because added keys are hidden, the original job and its coverage twin can look identical. A jwt-auth run shows the harder version: an include that differed from an existing job only in <code>coverage: xdebug</code> against <code>coverage: [none]</code> became a 45th job, and its name expression did not mention coverage, so two jobs are named <code>PHP8.5 - L12.* - prefer-stable</code>. The preview flags duplicate names. The same run names its PHP 8.0 jobs <code>PHP8</code>, because unquoted <code>8.0</code> in YAML is the number 8. <code>3.10</code> becomes 3.1 the same way, which is why version lists are quoted. The preview reports each number whose text changed on parsing, and separately warns about any exclude entry that matched no combination.</p>\n\n<h2 id=\"matrix-preview-limits\">Where the preview stops guessing</h2>\n\n<p>Matrices built from <code>${{ fromJSON(needs.setup.outputs.matrix) }}</code> are common, and their values exist only during a run. The contexts reference lists <code>github</code>, <code>needs</code>, <code>vars</code> and <code>inputs</code> as available to <code>jobs.&lt;job_id&gt;.strategy</code>; none of them exists in a browser. When the whole matrix, a variable's list, or the include or exclude list is an expression, the preview gives no count. When an expression is one value inside a list, it keeps the text, counts it as one value and marks the result approximate, because the job count is only right if it really is a single value.</p>\n\n<p>It also refuses three things GitHub does not document, instead of picking an answer: two variables whose names differ only in case (the syntax reference says names are case-insensitive, but not how two such lists combine), a YAML merge key, and an empty list. Object values are compared by deep equality in include and exclude, and are printed as JSON where they appear in a name, since GitHub's own rendering of them was not observed. The tool does not check runner labels, concurrency groups, <code>continue-on-error</code>, or whether a value works in your steps; that is how <code>--shard=</code> slipped through in the first place.</p>\n\n<h2 id=\"matrix-faq\">Questions about include, exclude and the job limit</h2>\n\n<p><strong>Can exclude remove a job include added?</strong> No. exclude runs first and only sees the product, so an exclude entry naming a key that only include sets matches nothing. The preview warns about that entry.</p>\n\n<p><strong>Does the 256-job limit count before or after exclude?</strong> The syntax reference says a matrix \"will generate a maximum of 256 jobs per workflow run\", which is the generated list, after exclude and include. A product of 300 that excludes trim to 200 is within it. The preview counts the final list and stops enumerating at 20,000 combinations.</p>\n\n<p><strong>Does max-parallel change the count?</strong> No. It caps how many of the jobs run at once; without it GitHub runs as many as runners allow. <code>fail-fast</code> defaults to <code>true</code>, which cancels in-progress and queued matrix jobs when one fails. The sample turns it off so one browser's failure does not hide another's.</p>\n\n<p><strong>How many combinations do I need?</strong> Usually fewer than the product. The same boundary-row reasoning in <a href=\"https://automationtester.in/blog/automation-tutorials/junit-parameterized-tests-without-matrix-explosion\">choosing JUnit parameter rows without a matrix explosion</a> applies to CI dimensions, and if the shards feed a flake gate, <a href=\"https://automationtester.in/blog/automation-tutorials/fail-ci-flaky-playwright-tests-evidence\">failing CI on flaky Playwright tests</a> covers merging their reports first. To see how a single value parses before it reaches the matrix, the <a href=\"https://automationtester.in/tools/yaml-formatter\">YAML Formatter</a> shows its JSON type.</p>\n\n<h2 id=\"matrix-apply-now\">Apply this now</h2>\n\n<p>Paste the workflow with your largest matrix into the preview. For every include entry, compare the order panel's \"merged into N jobs\" or \"added as a new job\" with the comment or commit message that introduced it, and fix any entry where they disagree by naming every matrix variable or giving it a conflicting value. Treat each warning that a created job lacks a variable as a bug until a step proves it handles the empty string. Copy the Markdown table into the pull request that changes the matrix, so the reviewer sees the job list instead of inferring it.</p>\n\n<h2 id=\"matrix-references\">GitHub Actions and Playwright references</h2>\n<ul>\n<li><a href=\"https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations\">GitHub Docs: Running variations of jobs in a workflow</a>: product order, the fruit and animal include walkthrough, exclude, fail-fast and max-parallel examples.</li>\n<li><a href=\"https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstrategymatrix\">GitHub Docs: Workflow syntax, <code>jobs.&lt;job_id&gt;.strategy.matrix</code></a>: the 256-job limit, case-insensitive variable names, the include rule, partial-match exclude, and include after exclude.</li>\n<li><a href=\"https://docs.github.com/en/actions/reference/workflows-and-actions/contexts\">GitHub Docs: Contexts reference</a>: a nonexistent property evaluates to an empty string, and the contexts available to <code>strategy</code>.</li>\n<li><a href=\"https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations\">GitHub Docs: Reusing workflow configurations</a>: YAML anchors and aliases in workflows.</li>\n<li><a href=\"https://yaml.org/spec/1.2.2/\">YAML 1.2.2 specification</a>: plain scalars such as <code>3.10</code> resolve to floats under the core schema.</li>\n<li><a href=\"https://github.com/microsoft/playwright/blob/v1.62.1/packages/playwright/src/cli/testActions.ts\">Playwright 1.62.1 <code>testActions.ts</code></a>: <code>resolveShardOption</code> returns no shard for an empty value and validates <code>current/all</code>.</li>\n<li><a href=\"https://playwright.dev/docs/test-sharding\">Playwright: Sharding tests</a>: the <code>--shard=x/y</code> option and merging sharded reports.</li>\n<li>Public workflow runs used for job names, checked 1 October 2026: <a href=\"https://github.com/pyvisa/pyvisa/actions/runs/34920686169\">pyvisa</a>, <a href=\"https://github.com/nats-io/nats.go/actions/runs/35386744413\">nats.go</a>, <a href=\"https://github.com/apache/commons-lang/actions/runs/33968028421\">commons-lang</a>, <a href=\"https://github.com/rhaiscript/rhai/actions/runs/36800860439\">rhai</a> and <a href=\"https://github.com/tymondesigns/jwt-auth/actions/runs/22759773260\">jwt-auth</a>.</li>\n<li><a href=\"https://automationtester.in/tools/github-actions-matrix-preview\">AutomationTester.in GitHub Actions Matrix Preview</a>: the sample, the order panel and the job tables shown above.</li>\n</ul>"}