# Why CI runs the same tests twice

CI runs the same tests twice because one change reaches it by several routes, and each route starts a run of its own. CI keeps no memory of the code it tested: it sees a new event, and starts from scratch.

## Push and pull request

A workflow triggered on both events runs twice for a push to a branch with an open pull request:

```yaml title=".github/workflows/ci.yml"
on: [push, pull_request]
```

Limit `push` to the branches that matter:

```yaml title=".github/workflows/ci.yml"
on:
  push:
    branches: [main]
  pull_request:
```

## New pushes while CI is running

A branch that gets another push leaves its earlier run going, testing code nobody will merge. [`concurrency`](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) keeps one run per group, and `cancel-in-progress` stops the older one:

```yaml title=".github/workflows/ci.yml"
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
```

Keep this for pull requests. On a branch that deploys, cancelling a run can stop a deploy halfway.

## The merge

With the configuration above, a merge to `main` runs everything again, on files the pull request already tested when its branch was up to date. The commit is new, so nothing tells CI the files aren't. This is the repeat most repositories pay for on every change.

Some of that run is worth keeping. If `main` moved after the pull request's last run, the merged files are new, and they should be tested.

## The merge queue

A merge queue tests each pull request against what's ahead of it, so it adds a run on purpose, on files that may not have existed before. Workflows need the `merge_group` event, or [the required check never reports](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue) and the merge fails:

```yaml title=".github/workflows/ci.yml"
on:
  pull_request:
  merge_group:
```

GitLab's [merge trains](https://docs.gitlab.com/ci/pipelines/merge_trains/) do the same on its Premium and Ultimate tiers. When a queue holds one pull request, its files often match what already passed, so the run repeats work.

## Staging, then production

With a `develop` branch for staging and `main` for production, each change is tested and built on `develop`, then tested and built again on `main`. The commits differ, but the files are the same. Here are two commits with the same source digest:

```console
$ hushrun digest --rev develop
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
$ hushrun digest --rev main
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
```

To stop rebuilding too, see [Build once, deploy many](https://hushrun.com/docs/guides/build-once-deploy-many.md).

## Make the repeats cheap

Configuration closes the routes you can name. What's left, such as a merge of what the pull request tested, needs CI to recognize the code. `hushrun run` does that: it records each pass under the files, the check's name and its command, whichever commit or branch they came from. On the merge, CI finds the pull request's pass:

```text
hushrun: test skipped: this exact code passed in CI 3 min ago (saves 41s)
```

If `main` moved, the files differ, and the check runs. A pull request from a fork gets no ID token, so it records nothing, and its merge runs in full. A push with the same files still spends the job's checkout and install, but not the check.

Set it up with `npx hushrun init`, as the [Quickstart](https://hushrun.com/docs/quickstart.md) shows.

## Related

- [Skip CI when the code hasn't changed](https://hushrun.com/docs/guides/skip-ci.md)
- [Reduce your GitHub Actions costs](https://hushrun.com/docs/guides/reduce-ci-costs.md)
- [The GitHub Actions cost calculator](https://hushrun.com/calculator)
