> For the complete documentation index, see [llms.txt](/llms.txt)

# Multi-VCS release flow

Glotto is **provider-agnostic from day one**. A single `VcsProvider` abstraction lets it push
regenerated SDKs and open release PRs across **GitHub, GitLab, and Bitbucket** — including
self-managed instances via `host` config and PAT fallbacks. **Gitea** and generic-git are on
the roadmap. Self-hosted GitLab, poorly served by GitHub-app-first competitors, is a
first-class target.

## How a release happens

1. A spec change is pushed; the provider's webhook is **ingested and normalized** into a
   provider-agnostic `GlottoVcsEvent` — provider-specific shapes never leak past the
   normalizer.
2. The **release-PR orchestrator** detects the spec diff, regenerates via the
   [pipeline](/docs/pipeline), and opens a PR with a changelog against the SDK target repo.
3. The per-provider **[drift-check](/docs/drift-detection)** workflow keeps committed output
   honest on every PR.

The orchestration layer programs against `VcsProvider` + `GlottoVcsEvent` only, so adding a
provider doesn't touch the release logic.

## Three branches: `generated` → `next` → `main`

Every SDK repository Glotto manages holds the same three branches, and each one answers a
different question:

| Branch | Holds | Written by |
| --- | --- | --- |
| `generated` | the **pristine** generator output for the current spec — never merged, never hand-edited | every regeneration |
| `next` | `generated` three-way-merged with your custom code, collecting changes for the next release | every regeneration |
| `main` | released code | merging the release PR |

`generated` is what makes custom code survive regeneration: it is the merge *base*, so Glotto
can tell your edits apart from its own previous output instead of guessing. That is why nothing
ever lands on it but a fresh generation — see
[custom code](/docs/custom-code) for the merge itself.

The release PR is always `next` → `main`. Merging it is the release.

You can rename any of the three per target with
[`targets.<lang>.release`](/docs/glotto-yml#targets), which is also the migration path for a
repository already on other branch names — a config line, not a migration. When a target sits in
a subdirectory of a shared repository (`repo_path`), `generated` and `next` each take a
per-language suffix so two SDKs in one repository never share a branch.

## Two repositories: staging and production

Every SDK target has **two** repositories, and they answer different questions.

The **staging repository** is hosted by Glotto, one per project per language, named
`<hosted-org>/<org>-<project>-<lang>`. It is created with your project, and every build lands there
first: the pristine output on `generated`, with `next` and `main` fast-forwarded to the same
revision. It carries **no custom code, ever** — which is what makes it the honest answer to "what
did Glotto generate from this spec?".

You can install from it directly, with your own forge credentials:

```bash
npm i github:<hosted-org>/<org>-<project>-<lang>
```

Two things to know before you run that. The staging repository is created **private**, so the
command needs a credential with read access to it — an authenticated remote, not an anonymous
clone. And the tree it installs is the *pristine* SDK: any custom code you keep in your production
repository is not in it, by design.

**A hand-edit pushed to a staging branch stops the next build** rather than being merged or
overwritten. Glotto fast-forwards `next` and `main` and never forces a ref, so a branch whose tip
is not on the build line makes the next build fail with `staging_diverged`, naming the branch.
Recovery is moving that branch back onto the build line — a force-push by someone with write access
on the hosted repository. Glotto does not do it for you: a staging repository that accepted
hand-edits would be a second production repository, and "which one is right?" would return.

The **production repository** is yours (`targets.<lang>.repo`). It is where the full release flow
above runs — `generated`, the three-way merge into `next`, the release PR `next` → `main`.
Selecting it needs an **organization-level connection for its provider** first: connect your VCS,
then pick the repository. Glotto reads the repository's default branch when you select it and
records it as `targets.<lang>.release.base_branch` when it is not `main`, so a `trunk` repository
integrates on `trunk` without any further configuration.

Staging is GitHub-only today. GitLab, Bitbucket and Azure Repos remain fully supported as
**production** repositories; hosted staging repositories on those forges are not offered yet.

### What a build reports

Each build of each target records one row, and its status says exactly how far it got:

| Status | Means |
| --- | --- |
| `building` | the build has started; nothing has been pushed yet |
| `build_failed` | generation did not complete (or your plan is out of builds) — nothing was pushed anywhere |
| `staging_failed` | the build could not be shown on staging, so it was **not** proposed to your repository |
| `staged` | staging holds the build; there is no production repository yet, or the release is set to `manual` |
| `production_failed` | staging holds the build and is intact; the release on your own repository did not complete |
| `released` | the release PR on your repository was opened or refreshed |

### What the orchestrator opens

Nothing below is typed by hand: both panes are what `runReleaseFlow` — the same orchestrator
step 2 describes — decided when a spec was pushed. It is running here against the in-memory
`VcsProvider` the release suite drives, so what you are reading is the release Glotto
*decided on*, not a transcript of one forge's API. Which forge it lands on is the part the
abstraction makes uninteresting, and the per-provider suites are what hold that.

**a spec pushed to `` `main` `` for the first time — the orchestrator opens the release PR**

A spec pushed to main for the first time. The regenerated SDK, on the next branch, as a PR against main.

**regenerated + byte-diffed in CI** `runReleaseFlow (@glotto/core-vcs)` `examples/stainless-migration/inputs` `3906fa591adb`

files in the release 19

branch next

```text
title   release: v0.1.0-alpha.1
rule    first release; alpha: no verification report
head    next
base    main
state   open

files   (19)
          README.md
          package.json
          sdk.test.ts
          src/client.ts
          src/core/client-core.ts
          src/core/model-fields.ts
          src/core/runtime.ts
          src/core/types.ts
          src/errors.ts
          src/index.ts
          src/lib/index.ts
          src/models/pet.ts
          src/models/store.ts
          src/models/vaccination.ts
          src/resources/pets-vaccinations.ts
          src/resources/pets.ts
          src/resources/stores.ts
          tsconfig.json
          tsconfig.test.json
```

Push a spec that gained one operation, and the same flow re-releases — with the SDK following:

**the spec gains `` `GET /health` `` — the same flow re-releases, and the SDK follows**

The spec gained GET /health. The client and its README moved; nothing else did.

**regenerated + byte-diffed in CI** `runReleaseFlow (@glotto/core-vcs)` `examples/stainless-migration/inputs` `c7245baf0840`

files in the release 20

branch next

```text
title   release: v0.1.0-alpha.1
rule    from the release PR title
head    next
base    main
state   open

files   (20)
          README.md   ← changed
          package.json
          sdk.test.ts   ← changed
          src/client.ts   ← changed
          src/core/client-core.ts
          src/core/model-fields.ts
          src/core/runtime.ts
          src/core/types.ts
          src/errors.ts
          src/index.ts   ← changed
          src/lib/index.ts
          src/models/pet.ts
          src/models/store.ts
          src/models/vaccination.ts
          src/resources/health.ts   ← changed
          src/resources/pets-vaccinations.ts
          src/resources/pets.ts
          src/resources/stores.ts
          tsconfig.json
          tsconfig.test.json
```

## Where the version number comes from

The release PR's version is **derived**, not chosen, and the PR body's `Version:` line names the
rule that produced it — so a reader never has to go and find out why a number is what it is. Four
inputs, read in this order:

1. **The PR title wins.** A release PR is titled `release: vX.Y.Z`, and a version written there is
   honored verbatim — nothing below is consulted. It is the one place a human sets `1.0.0` or a
   channel Glotto does not know about.
2. **The bump comes from the contract diff.** An operation or model **removed or changed** is a
   major bump (a minor one below `1.0.0`); something **added** is a minor bump; no API change is a
   patch. Below `1.0.0` the derivation never crosses to `1.0.0` — `0.9.0` with a removed operation
   becomes `0.10.0` — because that line is a product decision, not an arithmetic one.
3. **The prerelease channel comes from your latest verification report.** A **failed** compile or
   contract check — or a hand-edited managed file — is `-alpha.N`. Checks that have **not run** are
   `-beta.N`. Only a report where everything passed produces a plain `X.Y.Z`.
4. **No report is `-alpha.N`, not a plain version.** A bare version is a claim that the build was
   verified, and "we could not find the report" is not that claim. A project that has not set up
   check ingestion therefore sees `-beta.N` once its reports arrive and `-alpha.N` until then; a
   diff Glotto could not compute is a patch bump whose rule line says so, never "no API change".

Two consequences worth knowing:

- **An open release PR keeps the version it was opened with.** The channel is decided when the PR
  opens and frozen for its lifetime, exactly as the patch number already is — because rule 1 reads
  the title the PR already carries. Once your checks pass, edit the title to the version you want
  (or close the PR so the next build opens a fresh one).
- **A prerelease never becomes the default install.** An `-alpha.N` / `-beta.N` npm publish goes out
  under that channel's dist-tag (`alpha`, `beta`, or npm's `next` for any other identifier), never
  `latest`. pip, RubyGems, NuGet, Hex and pub already treat a prerelease suffix as opt-in.

## Review ownership — one block, four forges

The same abstraction covers **who reviews** the SDK repos Glotto manages. Declare it once in
[`code_owners`](/docs/glotto-yml-project-settings#code_owners) and each target's forge receives it the way that forge
implements review ownership — which is not the same way in any two of them:

| Provider | How ownership arrives | Path scoping |
| --- | --- | --- |
| GitHub | `.github/CODEOWNERS`, emitted by `glotto generate` | Per rule |
| GitLab | `.gitlab/CODEOWNERS`, emitted by `glotto generate` | Per rule |
| Bitbucket | Repository default reviewers, via code-owner application | **None** — repo-wide |
| Azure Repos | A required-reviewers branch policy, via code-owner application | Per rule |

The honest deliverable is *review ownership per provider*, not "a CODEOWNERS file", and the part
that matters is what happens where the mapping is imperfect. Bitbucket's default reviewers cannot be
scoped to a path, so code-owner application tells you **which of your patterns it had to
flatten** instead of reporting a plain success. An owner no workspace member matches comes back
named, with the reason, while the owners that did resolve still apply. And a target whose SDK lives
under a [`repo_path`](/docs/glotto-yml#targets) subtree gets no file at all — GitHub and GitLab read
CODEOWNERS only from the repository root — which `glotto generate` and `glotto generate` both say
out loud rather than emitting a file nothing will ever open.

That last property is the point. A governance feature that quietly does nothing on half your
repositories is worse than one you know you have to configure by hand.

## Preview builds — try the SDK before you merge

The same normalized webhook powers **preview builds**: when a pull request is opened on your
spec repo, Glotto regenerates every SDK target and posts one comment on the PR carrying, per
target, its build status, a file-level diff against the last released build, and a **working
install command**.

| Target | How you install a preview |
| --- | --- |
| TypeScript, React Native | `npm install '<url>'` |
| Python | `pip install '<url>'` |
| The other ten languages | Download the archive, then use a local-path dependency (command below) |

For those languages, run `curl -L '<url>' | tar xz`, then point the project at the unpacked
local path.

Install links are unguessable and **expire after 14 days** — treat one as a secret, since
anyone holding it can install that build. Previews are **opt-in per project**
(`preview.enabled`) and off by default.

This is the guarantee made tangible: rather than trusting that regeneration will do the right
thing at release, you hold the regenerated artifact while the change is still reviewable.

## When your spec lives at a URL

The flow above starts with a **push**. If your OpenAPI document is published at a URL rather than
committed to a repository Glotto is connected to, there is no push to react to — so Glotto can
**watch the URL instead**.

Point [`openapi.source`](/docs/glotto-yml)
at your published document, as you already would:

```yaml
openapi:
  source: https://api.example.com/openapi.yaml
```

Then enable polling for the project: set `auto_poll` on the project's verification source (with
`poll_interval_minutes` if you want a cadence other than the hourly default). On its cadence Glotto
reads **your repository's own `glotto.yml`**, resolves the URL that file names, and fetches it. When the document has changed, it
records the revision and runs exactly the release above — the same regeneration, the same
three-way merge with your custom code, the same release PR. Push-triggered and poll-triggered
releases converge on **one** open release PR, so running both is the intended configuration rather
than a conflict.

Four things worth knowing before you turn it on:

- **It is opt-in per project and off by default.** A poll that finds a change regenerates, and
  regeneration counts against your plan — so no existing project starts doing this because the
  feature shipped.
- **An unchanged spec costs nothing.** Each poll sends a conditional request; an origin that
  answers "not modified", or that serves bytes identical to the last revision, produces no
  regeneration and consumes no quota. The default cadence is hourly, configurable per project.
- **The address stays in your repository.** Glotto never stores a copy of your spec URL — it reads
  `openapi.source` from your `glotto.yml` every time. Changing where your spec lives is a commit
  you review, not a setting in someone else's database, and it means a local `glotto generate` and
  a hosted regeneration can never disagree about which document they used.
- **The document has to be reachable without credentials.** `openapi.source` takes a URL, not a URL
  plus headers, so there is nowhere to put an API key — a spec behind an `Authorization` header
  cannot be polled. This is the same limit a local `glotto generate` against that URL already has,
  not one polling adds. If your spec is private, commit it to a connected repository and use the
  push flow above.
- **Breaking-change detection still needs a committed baseline.** A URL serves one current
  document, so there is no older per-branch version to diff against — unlike a spec in git, where
  the previous commit is the baseline. The
  [breaking-change check](/docs/breaking-changes) covers this case by diffing against a baseline
  document you commit. Polling keeps your SDKs current; it does not by itself give a URL source the
  history a repository has.
