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
- 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. - The release-PR orchestrator detects the spec diff, regenerates via the pipeline, and opens a PR with a changelog against the SDK target repo.
- The per-provider drift-check 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 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, 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:
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.
- files in the release 19
- branch next
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
runReleaseFlow (@glotto/core-vcs) examples/stainless-migration/inputs 3906fa591adb 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.
- files in the release 20
- branch next
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
runReleaseFlow (@glotto/core-vcs) examples/stainless-migration/inputs c7245baf0840 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:
- 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 sets1.0.0or a channel Glotto does not know about. - 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. Below1.0.0the derivation never crosses to1.0.0—0.9.0with a removed operation becomes0.10.0— because that line is a product decision, not an arithmetic one. - 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 plainX.Y.Z. - 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.Nonce its reports arrive and-alpha.Nuntil 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.Nnpm publish goes out under that channel’s dist-tag (alpha,beta, or npm’snextfor any other identifier), neverlatest. 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 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 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
at your published document, as you already would:
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.sourcefrom yourglotto.ymlevery time. Changing where your spec lives is a commit you review, not a setting in someone else’s database, and it means a localglotto generateand a hosted regeneration can never disagree about which document they used. - The document has to be reachable without credentials.
openapi.sourcetakes a URL, not a URL plus headers, so there is nowhere to put an API key — a spec behind anAuthorizationheader cannot be polled. This is the same limit a localglotto generateagainst 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 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.