# Promote main to other branches

> Move a branch such as `staging` only to a checked commit from main, and see which commit is ready.

Source: https://getbay.io/docs/promote (HTML).

A promotion moves a branch such as `staging` or `production` to a commit that is already on main. `[[target]]` in `.bay/config.toml` names each such branch and the rule it moves by. The rule names the checks that must pass, and how old the commit must be. `[[freeze]]` holds a branch until a set time, except for fixes and undos.

bay has no promote command. A person promotes with a plain `git push` to the branch. Or, with `advance = true`, the executor moves the branch for you. bay shows each branch's state in `bay status`, and each move in `bay watch`. On a host from `bay host init`, the hook refuses a push that breaks the rule. On any other host, bay sees a bad move only after the push. bay deploys nothing. Your deploy tool reads the branch.

## When to use promotion

- **Stage a release**: `staging` takes only main commits whose required checks passed.
- **Soak before production**: `production` takes a commit from `staging` only once it is old enough.
- **Freeze for a holiday**: until the freeze ends, only fixes and undos reach the branch.
- **See what is ready**: `bay status` names the newest commit you can promote now.
- **Follow green main**: with `advance = true`, the executor moves `staging` to each new promotable commit.

## Declare a target

Add one `[[target]]` per branch to `.bay/config.toml` on main. `name` names the target, and `ref` defaults to `refs/heads/<name>`. `from` is main by default, or the name of another target, such as `staging`. `requires` lists check ids from `.bay/policy.toml`, for example your post checks. `soak_s` is the minimum age of the commit in seconds, by its committer time. It defaults to 0.

```toml
[[target]]
name = "staging"
requires = ["integration"]         # check ids from .bay/policy.toml

[[target]]
name = "production"
from = "staging"                   # promoted from staging, not from main
requires = ["integration"]
soak_s = 86400                     # the commit is at least one day old
```

> A target's ref is a full ref under `refs/`. It is never main's, and never one of bay's own refs.
> A config with two targets of one name or ref, an unknown `from`, or a `from` cycle does not load.

## Freeze a target

`[[freeze]]` names a `target`, an `end` time in UTC to the second, and a `reason`. Until `end`, the target takes only a commit with a `Fixes:` or `Reverts:` trailer. `bay start --fixes` and `bay undo` write those trailers on the main commit. bay reads freezes from main's tip only, so a freeze you remove there ends at once. A `[[freeze]]` whose `target` names main holds main itself. Configuration explains that hold.

```toml
[[freeze]]
target = "production"
end = "2026-12-27T00:00:00Z"       # RFC 3339 UTC, to the second
reason = "holiday freeze"
```

## Promote a commit

Read the target's row in `bay status`, then push its `promotable.commit` to the target's ref. `behind_from` counts the commits between that commit and the source's tip. When no commit is ready, `promotable.blocked_by` says why the newest one fails.

```sh
commit=$(bay status --json | jq -r '.detail.targets[] | select(.name == "staging") | .promotable.commit')
git push origin "$commit:refs/heads/staging"
```

## Let the executor promote

Set `advance = true` on a target, and each pass of `bay executor run` moves it for you. It moves the target to its newest promotable commit, the one `bay status` names. Before the push, it judges that commit again by the same rule as the host hook. It pushes under a lease on the tip it read, so it never goes backwards or over another push. A target promoted from another target moves after its source, in the same pass. A target with `advance = true` needs `requires`, or the config does not load.

```toml
[[target]]
name = "staging"
requires = ["integration"]
advance = true                     # each executor pass moves staging to its newest promotable commit
```

> `bay executor run --once` lists each move under `detail.advanced`. A looping executor prints a failed move on stderr.
> `bay land --wait` never moves a target. Run an executor for targets that advance.
> Without `advance`, bay never moves the target.

## See each target

`bay status` fetches the target refs, then lists one row per target under `detail.targets`. The fleet map lists the same rows from its last fetch. A row bay cannot judge says why under `unread`, and never reads as ready.

```sh
bay status --json | jq '.detail.targets'
```

| Field | Meaning |
|---|---|
| `tip` | The commit the target's ref points at, or `null` when the remote has no such ref. |
| `from`, `from_tip` | The source, main or another target, and its tip. |
| `on_from` | Whether the tip is on the source's first-parent line, or is a bay merge that a merge outside bay put off it. |
| `drift` | Commits on the target that the source does not reach: `count`, and up to 5 in `commits`. |
| `lag` | How many first-parent commits of the source the target is behind. |
| `requires`, `soak_s` | The rule in force, with the config at the target's tip added. |
| `checks` | Each required check at the tip: `pass`, `product_failure`, `flaky`, `unknown`, `pending`, `unknown_check` or `no_policy`. |
| `freeze` | The freeze in force, with `end` and `reason`, else `null`. |
| `tip_failures` | The rules the tip breaks: off the source's line, or a required check that does not pass. |
| `promotable` | The newest commit you can promote now, with `behind_from`. Else `blocked_by`, or `at_from` when the target is at the source's tip. |
| `fetched` | `false` when bay read the copies from its last fetch. |

## Watch targets move

Outside a view, `bay watch` lists one event each time a target's tip changes. `target_moved` means the new tip meets its rule. `target_unpromotable` means the tip is off its source's line, or a required check does not pass. Each event names `target`, `ref`, `commit`, `from` and `failures`. Without a cursor, `bay watch` lists `target_unpromotable` for each target whose tip fails now.

```sh
bay watch --json | jq '.detail.events[] | select(.kind | startswith("target_"))'
```

## How bay judges a promotion

- The commit is on the first-parent line of the source's tip, and fast-forwards the target.
- Every check that `requires` names passes on the commit's tree, judged as a post check.
- A check id that the policy does not declare never counts as a pass.
- The commit's committer time is at least `soak_s` old.
- No freeze is in force, or the commit carries `Fixes:` or `Reverts:`.
- bay adds the `requires` and `soak_s` from the config at the target's tip, so a rule never weakens.

> With a key registry, only signed check records count. Without one, records count unsigned, as everywhere else.
> A bay merge that a merge outside bay put off the source's first-parent line counts as on it.

## What promotion cannot do

- bay moves a target only when it sets `advance = true`. Otherwise a person or your tooling pushes it.
- bay deploys nothing. Your deploy tool reads the target branch.
- bay reads the check records that exist. It starts no check to make a commit ready.
- Off a host from `bay host init`, bay cannot stop a bad push. It reports it afterwards.
- `target_unpromotable` judges the line and the checks only. Soak and freeze apply when you push.

## Refusals

On a host from `bay host init`, the hook refuses the whole push with `PROMOTION_REFUSED`. The message names the target's ref and each rule the commit breaks. The hook judges a target push with or without a key registry. It also refuses any push that deletes a target. With Claude Code hooks, the hook refuses an agent's own `git push` of a target with `SHELL_DENIED`. The Results page lists what to do next.

| Rule | Meaning |
|---|---|
| `not_on_from` | The commit is not on the first-parent line of the source's tip, nor a bay merge that a merge outside bay put off it. |
| `not_fast_forward` | The push does not move the target forward from its old tip. |
| `check` | A required check does not pass on the commit. |
| `soak` | The commit is younger than `soak_s`. |
| `freeze` | A freeze is in force, and the commit carries no `Fixes:` or `Reverts:`. |

## Limits

- **Search**: `promotable` reads at most 50 first-parent commits of the source, newest first.
- **Drift**: a row names at most 5 drift commits, and counts them all.
- **Clock**: the hook judges soak and freeze by the host's clock, and `bay status` by yours.
