---
title: "Explicit Vitest Run Mode in CI | StarSling"
description: "GitHub Actions sets CI=true in every job, so vitest already exits after one pass. Pin run mode to the command and bound the job with timeout-minutes."
url: https://starsling.dev/github-actions/optimizations/optimize-vitest
canonicalUrl: https://starsling.dev/github-actions/optimizations/optimize-vitest
---

# Make vitest run mode explicit in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Make vitest run mode explicit in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.hygiene.vitest-run-mode. Detection mode: static. Last updated: 2026-08-21

GitHub Actions sets `CI=true` in every job, and vitest reads that variable to choose run mode over watch mode, so a bare `vitest` already executes the suite once and exits. Writing `vitest run` ties that behaviour to the command instead of to the environment, and a `timeout-minutes` bound caps any test process that stops making progress.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

vitest picks its mode at startup: it enters watch mode in a development environment and run mode in CI or a non-interactive terminal, and a truthy `CI` environment variable is one of the signals it reads. GitHub Actions sets `CI` to `true` in every job it runs, so on a GitHub-hosted runner the bare command already does the right thing. What the explicit `run` buys is that the guarantee travels with the command rather than with the environment around it. The same workflow step gets lifted into a container step that scrubs the environment, copied onto a self-hosted runner image, wrapped in a script that runs it under a shell with a cleaned environment, or reused on another CI system that never sets `CI` - and in each of those the implicit signal is the part that changes while the command stays identical. The second half of this page is the bound that matters regardless of mode: a job with no `timeout-minutes` inherits the 360-minute per-job maximum, so any test process that hangs on an open handle, a socket, or a container that never becomes ready holds a runner for six hours before anything reclaims it.

_.github/workflows/test.yml_

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm vitest run
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/test.yml_

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    # No timeout-minutes: a suite that hangs holds the runner for 360 minutes.
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      # Mode is decided by the CI env var rather than by the command.
      - run: pnpm vitest
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Search workflows for vitest invocations that leave the mode implicit: `grep -rn 'vitest' .github/workflows/ | grep -vE 'vitest([[:space:]]+[^[:space:]]+)*[[:space:]]+(run|--run)([[:space:]]|$)'` - `vitest run` and `vitest --run` both pin run mode, and the flag can sit anywhere after the command (`vitest --coverage --run`), so the filter scans the whole invocation rather than the next token.
2. Confirm the environment the command actually inherits. GitHub-hosted runners always export `CI=true`, so a surviving match there is about explicitness; a step that runs inside a `container:` with a curated `env:` block, a self-hosted runner, or a wrapper script that resets the environment can reach vitest without it, and that is where the implicit form changes behaviour.
3. Confirm the job has a `timeout-minutes`: `grep -n 'timeout-minutes' .github/workflows/*.yml`. A vitest job with no timeout has the 360-minute per-job maximum as its only bound, which applies to a hung suite in run mode just as much as to a watcher.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- On a GitHub-hosted runner this change is about where the guarantee lives, not about wall-clock time: `CI=true` is already set, so the suite was already running once and exiting. Expect the run duration to stay the same and treat the win as portability of the workflow step.
- If a package script already wraps vitest (`"test": "vitest run"`), pin the mode in that script and have the workflow call the script, so a developer running it locally gets the same behaviour as CI.
- Size `timeout-minutes` a few minutes above the slowest observed passing run of that job. A bound set near the average turns a legitimately slow run into a red build, which costs more than the runaway it was meant to catch.

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's vitest invocations in CI so each one pins its own run mode,
and make sure every job that runs them is time-bounded.

1. grep .github/workflows/ for vitest commands. Flag any invocation that does not carry
   `run` or `--run`. On GitHub-hosted runners these already execute once because the
   runner sets `CI=true`; the change makes that independent of the environment.
2. Change each flagged invocation to `vitest run`. If a package script wraps vitest,
   pin the mode in that script and leave the workflow calling the script.
3. Check each affected job for `timeout-minutes`. If it is missing, add a value a few
   minutes above the slowest observed passing run of that job, not an arbitrary guess.
4. Read the Vitest CLI docs linked on this page before editing, to confirm the project's
   vitest version and config do not already pin the mode some other way (a config file
   `watch: false`, a custom reporter, or a wrapper script).
5. Show the full diff and open a pull request; do not apply changes blindly. In the PR
   body, list each workflow file and job you changed, and state how to verify: re-run the
   workflow, confirm the suite still exits on its own, and confirm the job now reports the
   `timeout-minutes` you set.
```

Confirm the change landed:

1. Re-run the workflow and confirm the vitest step exits on its own and the job finishes shortly after the suite's reported duration.
2. Confirm the mode is now pinned to the command by running the same invocation with the variable cleared - `env -u CI pnpm vitest run` locally, or in a container step with no `CI` in its `env:` block - and checking it still executes once and exits.
3. Read the job's configuration in the run summary and confirm `timeout-minutes` is present and is the value you intended, rather than the inherited 360-minute maximum.

<a id="related-pages"></a>

## Related pages

- [Bound GitHub Actions runner jobs with timeout-minutes](https://starsling.dev/best-practices/github-actions/bound-job-timeouts)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [Speed up Playwright tests in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-playwright)

<a id="sources"></a>

## Sources

- [Vitest: Command Line Interface](https://vitest.dev/guide/cli)
- [Vitest: config reference (watch)](https://vitest.dev/config/#watch)
- [GitHub Actions: default environment variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)
- [GitHub Actions: workflow syntax (jobs.<job_id>.timeout-minutes)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idtimeout-minutes)
