Make vitest run mode explicit in GitHub Actions

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.

ci.hygiene.vitest-run-modestatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

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
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

Avoid this

.github/workflows/test.yml
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

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.

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.

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.

Prompt for your coding agent
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.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT55 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (this one plus 72 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1Vitest: Command Line Interface (opens in new tab)

2Vitest: config reference (watch) (opens in new tab)

3GitHub Actions: default environment variables (opens in new tab)

4GitHub Actions: workflow syntax (jobs.<job_id>.timeout-minutes) (opens in new tab)

Last updated 2026-08-21