Cutting a release is one command: scripts/cut-release.sh <version>. The script
owns the mechanical flow so it is not re-derived (and re-broken) by hand each time
(#879). You own the decisions: whether to cut, the version, and rc-vs-GA.
Releases follow ADR 0102, which amends ADR 0062:
-
mainnever freezes. Every change merges tomainfirst, including a fix meant for a patch release. -
Releases are cut from
mainby default.X.Y.0, its candidates and every later patch of the minor (X.Y.Z-rc.N,X.Y.Z) are prepared, merged and tagged onmain, as long as everything onmainmay ship in the next patch of that minor under the safety bar of ADR 0068.scripts/release-base.sh <version>prints the branch a version is cut from, and the cut, the release gap and the docs gap all ask it. -
A release branch opens only when
maindiverges. The first change that may not ship in the minor's next patch (a feature of the next minor that is not ready, a breaking change) opens one: createrelease-X.Yfrom the minor's newest GA tag, and add the lineX.Y release-X.Yto.github/patch-branches.txtin the pull request that brings that change.git push origin vX.Y.Z^{commit}:refs/heads/release-X.YFrom then on the minor's patches are cut from
release-X.Y, and the cut refuses a patch frommainoncemain's chart names a later minor. -
Fixes reach a mapped release branch only as cherry-picks. Once the change is merged to
main, it goes torelease-X.Ywithgit cherry-pick -x <sha>(the-xrecords the original commit), in one[release-X.Y] pickspull request per day or per candidate rather than one per change. Carry each change's changelog fragment with it: patch notes are folded from the fragments on the release branch. Picks keep the migration order ofmain. -
History: patches v0.5.1 to v0.5.8 came from
release-0.5under ADR 0062. Nothing was ever held back from that branch, so from v0.5.9 on the 0.5 patches come frommainagain andrelease-0.5takes no more picks. -
Every PR to
mainnames its release as a milestone (v0.5.1,v0.5.2, ...). The milestone guard check fails a PR without one. A PR milestoned for the patch being cut needs its cherry-pick; one milestoned for a later release stays onmainuntil then. -
Nothing on
mainis left behind by accident (mapped minors only; for a patch cut frommainthere is nothing to pick).scripts/release-gap.sh X.Y.Zlists every commit onmainsincevX.Y.0thatrelease-X.Ydoes not carry, ignoring PRs milestoned for a later release and the lines of.github/release-skip.txton the release branch (#N reasonor<sha> reason, for what will never ship in this minor, such as an ADR). The cut runs it and refuses a patch while the list is not empty. The commits the cut itself lands onmain(release prep, docs promotion) never count; a Dependabot bump has no milestone, so it shows in the list until it is cherry-picked or skipped. Between cuts theRelease gap watchworkflow runs the same check after every push tomainand once a day, and keeps an issue titledRelease gap: release-X.Yopen while the list is not empty, with how long ago each commit merged and its open pick pull request. -
Nothing user-facing ships undocumented.
scripts/docs-gap.sh X.Y.Zlists every commit the release ships since the previous GA (on the branchscripts/release-base.shnames for the version) that changes user-facing surface without editingwebsite/content/: a chart value, the authoring schema, a CLI command, a server setting (internal/config), a migration, the OpenAPI document, or a changelog fragment of kind Added, Changed, Deprecated or Removed. Such a commit passes when its PR (or, for a cherry-pick, the original PR onmain) carries theskip-docslabel and aSkip-docs: <reason>line in its description, or when.github/docs-skip.txton the branch being cut lists it (#N reasonor<sha> reason, for example#1352 documented in #1477). The cut runs it for every rc and GA and refuses while the list is not empty. The docs guard applies the same rule to each PR when it is opened, so the list is normally empty; it catches what the per-PR check could not, such as a skip with no reason or a docs PR that was promised and never merged. The Docs gap workflow (docs-gap.yaml, #1938) runs it before the cut too, on every push torelease-X.Y(for the next patch) and on every rc tag, together withcheck-stale-versions.sh --candidatefor the same version, and fails with the list in its job summary. Its run on the candidate's commit is the evidence of the Docs line in the release's tracking issue. ASkip-docs:line added to a pull request afterwards needs a re-run of that job, orgh workflow run docs-gap.yaml --ref <branch> -f version=X.Y.Z. -
The compatibility page is read before each minor. Before cutting
vX.Y.0-rc.1, readreference/compatibility.mdagainst the CI jobs it links and add the new release line's table (#1566). -
Only the newest release branch takes patches. The previous one takes security fixes for 8 weeks after the next minor's
X.Y.0final ships, and nothing after that. The versioning and support policy is the public promise; keep the two in step.
release-X.Y is a different name from the short-lived release/<tag> prepare
branches the script opens, so the two never collide. Nothing ever merges a
release branch back into main.
CI, the changelog and docs guards and the security scans run on pull requests
to release-* as they do on main, and CI and the security scans also run on
pushes to release-*, so the merge commit a patch is tagged from has a run to
gate on.
CHANGELOG.md is assembled, not edited. Each pull request records its change
as one fragment file, the cut folds the fragments into the file, and after a
patch the result is brought back to main. The contributor side (how to write
a fragment, what not to edit) is in
Record a change;
this section is the whole lifecycle.
- A pull request to
mainwrites a fragment.make changelog(changie) writes.changes/unreleased/<timestamp>-<slug>.yamlwith akind(one of the kinds in.changie.yaml) and abody. A pull request with no user-facing change carries theskip-changeloglabel instead. - For a mapped minor, a cherry-pick carries it to
release-X.Y.git cherry-pick -xcopies the fragment with the same file name and the same text. Keep both: the sync in step 5 matches the fragment onmainto what shipped by that name, and by its text. - The cut folds the fragments, on the branch it cuts from.
scripts/changelog-fold.shrenders every pending fragment into## [Unreleased]and deletes the files, in the prepare commit. An rc stops there; its entries wait in[Unreleased]for the GA. - A GA dates the section.
[Unreleased]becomes## [X.Y.Z] - <date>and a fresh, empty[Unreleased]opens above it. - A patch GA from a release branch syncs back to
main. ForX.Y.ZwithZ > 0cut fromrelease-X.Y, steps 3 and 4 happened there, somainstill has every fragment the patch shipped and no[X.Y.Z]section. Right after pushing the tag, the cut runsscripts/changelog-sync.sh X.Y.Z --pr, which openschangelog: sync vX.Y.Z to mainfrom a branchchangelog/sync-vX.Y.Z: the dated sections of the minor's patches thatmainlacks, copied verbatim from the tag in version order, and the deletion of the fragments the release consumed. The cut does not wait for it, and its failure never fails the cut; merge it once it is green. It depends only on the tag, not on the release workflows reaching PUBLISHED: the changelog is true once the tag exists. The milestone guard exempts the branch, andrelease-gap.shanddocs-gap.shtreat its commit as release mechanics. When the tag's Release workflow completes green, the Docs promote workflow'schangelog-syncjob runs the same command unless the sync pull request is already open, so a patch tagged without the cut, as v0.5.4 was, gets its pull request too (#1937). Its commit is signed off bygithub-actions[bot], and with theDOCS_PROMOTE_TOKENsecret its checks start on their own (see Publishing the docs root). X.Y.0, and every patch cut frommain, fold onmain. Steps 3 and 4 happen onmainitself, so there is nothing to sync. This is the cut that would publish an unsynced patch's fragments a second time, which is why it checks first (below).
main release-X.Y
| PR + fragment |
|---cherry-pick -x--> same fragment, same file name
| |
| | cut X.Y.Z (Z > 0): fold, delete fragments, date [X.Y.Z]
| o tag vX.Y.Z
|<--sync PR--------' changelog: sync vX.Y.Z to main
| + [X.Y.Z] section, - consumed fragments
|
o cut X.Y+1.0 on main: fold (only what never shipped), date, tag
What checks it, and when:
| Check | What it checks | When it runs |
|---|---|---|
check-changelog-entry.sh (CHANGELOG guard) |
The pull request adds a fragment or edits [Unreleased], or carries skip-changelog |
Every pull request to main and release-* |
check-changelog-structure.sh (CHANGELOG structure) |
One [Unreleased], first; every other heading ## [X.Y.Z] - YYYY-MM-DD, strictly descending; only declared kinds, once each, in order, none empty (from 0.4.7 on); every GA tag of a minor the file records has its section, except the newest tag of each minor while its sync pull request is open; every fragment parses, has a declared kind and a non-empty body, and is not already an entry of a dated section |
Pull requests to main and release-* that touch the changelog, the fragments or these scripts; and every cut, as one of the check-*.sh gates |
changelog-sync.sh --check-all |
main carries the changelog of the newest patch of every minor: no missing section, no fragment the release consumed |
Preflight of every cut. A minor cut (from main) refuses; a patch cut warns, because its own sync repairs the earlier ones. Also daily on main, and on pushes to main that change CHANGELOG.md (the Changelog sync workflow, changelog-sync-check.yaml): it fails while a patch lacks its sync and no sync pull request is open, and reports an open one as a notice |
changelog-fold.sh |
The fold drops no line of the file | Every cut |
- The sync pull request failed to open, or was closed unmerged. Nothing
about the release is affected. From any checkout:
scripts/changelog-sync.sh X.Y.Z --pr --fetch. It works from the tag in a scratch worktree oforigin/main, never switches your checkout, and re-running it updates the same branch and pull request. Or run the Docs promote workflow with the tag (gh workflow run docs-promote.yaml -f tag=vX.Y.Z), which opens it when none is open. The Changelog sync workflow fails every day until one is. - A patch was synced late or never. Syncing the newest patch of the minor
also inserts every earlier section the minor is missing, so
scripts/changelog-sync.sh <newest X.Y.Z> --pr --fetchrepairs them all.scripts/changelog-sync.sh --check-all --fetchsays which minor is behind. - CHANGELOG structure is red on a pull request. It names the rule and the line. "vX.Y.Z is tagged but has no section" is a missed sync (above). "already an entry of a dated section" is a fragment that shipped: delete it in the same pull request. A malformed heading or a fragment that does not parse is fixed in the pull request that is red.
- A fragment was renamed on one side. The sync still removes it when its
text matches a released entry, and says it matched by content. When a
fragment of the consumed name is on
mainwith different text (edited after the pick), the sync keeps it, reports it, and exits 1: decide by hand whether it is the shipped entry (delete it) or a new one (keep it, give it a new name). - Preview without changing anything:
scripts/changelog-sync.sh X.Y.Z --check --fetchprints what a sync would insert and delete.scripts/changelog-sync.sh X.Y.Z(no flag) applies the sync to the checkout you are in, for a pull request you open yourself.
- Preflight: required tools, clean tree, version validated, rc-vs-GA
detected from the
-rc.Nsuffix, and the base branch picked from the version byscripts/release-base.sh:main, orrelease-X.Yfor a patch of a minor mapped in.github/patch-branches.txt(it must exist onorigin). A patch frommainrefuses oncemain's chart names a later minor. Being checked out on another branch only warns; the cut builds onorigin/<base>either way. It also runsscripts/changelog-sync.sh --check-all: a cut frommainrefuses whilemainlacks the changelog of a patch already released, and a patch cut warns. - Prepare: a
release/<tag>branch off the base. Bumphelm/dexaflow/Chart.yamlversion+appVersionin lockstep (ADR 0028), regenerate the chart README withhelm-docs, fold the pending changelog fragments into[Unreleased], and for a GA moveCHANGELOG [Unreleased]to[X.Y.Z] - <date>with a fresh empty[Unreleased](an rc keeps[Unreleased]). See How the changelog flows. Run everyscripts/check-*.shgate against the tag; the set must equal.github/ci/gates.txt, and the cut names any gate that is missing or unexpected. One of them,check-changelog-entry.sh, is a pull-request gate with no question to ask when there is no pull request, so it reportsgate SKIP; the cut log distinguishes SKIP from PASS to keep the count honest. Another,check-stale-versions.sh, runs as--candidate <tag>: it judges the pages against the release being cut, becauseversions.jsonstill names the GA that release replaces (onrelease-X.Y, an older one still), and v0.5.4 was tagged with pages that pinned 0.5.3 (#1934). - PR → wait green → merge: opens the prepare PR against the base and waits
for CI, re-running only known-transient flakes (registry rate-limits, Go
module-proxy resets, the shallow-fetch merge-base gate, the cold-start
/readyztimeout) and never hard-failing on them; then squash-merges. - Guard → tag: verifies the Chart at the merge commit matches the version, waits for the base branch's CI on that commit, then, behind an explicit confirmation gate, tags and pushes.
- Sync the changelog to main (patch GA only): right after the tag
push and the
.release-<tag>.logline, openschangelog: sync vX.Y.Z to main(scripts/changelog-sync.sh X.Y.Z --pr) and moves on without waiting for it. A failure warns and prints the command to run by hand; it never fails the cut. - Watch: writes
.release-<tag>.logthe moment the tag is pushed, then follows the tag's release workflows to PUBLISHED, un-drafting + re-running if the gate retracts on a flake (#862). PUBLISHED means the release run is green, which includes itspromotejob (see below). - Publish the docs root (GA only, and only if step 6 reached
PUBLISHED): runs
scripts/docs-promote.sh <tag> --pr, which opens adocs/promote-<tag>PR repointingwebsite/scripts/ci/versions.json, or finds the one the Docs promote workflow already opened, then waits for it green and merges it. It runs last on purpose: the Pages deploy checks the tag out, so it cannot ride in the prepare commit; and the site root must never advertise a release whose artifacts are red or still draft. A failure here costs the docs root and nothing else, since the release is already out and logged. The promotion always targetsmain, also for a patch tagged onrelease-X.Y, and it leaves the root alone when the GA being cut is older than the one the root serves. See below.
A tag stages, the gate verifies, and one job promotes
(ADR 0071).
Everything release.yaml pushes before its gate job is immutable and
versioned; only the promote job, which needs a green gate, moves a pointer
that people follow without naming a version.
| What | Who follows it | Moved by | rc | GA, newest minor | GA, older minor |
|---|---|---|---|---|---|
Binaries, dexaflow-server, dexaflow-mcp, dexaflow-cli, dexaflow-migrate at vX.Y.Z and X.Y.Z, dexaflow-runtime:py3.N-vX.Y.Z |
pinned installs, the setup-dexaflow action, pipelines |
release, migrate-image, runtime-image |
yes | yes | yes |
| GitHub release, public, not Latest | pinned install.sh, the smokes |
GoReleaser (make_latest: false) |
prerelease | yes | yes |
Helm chart X.Y.Z (charts/dexaflow, charts/leoflow) |
helm install, helm upgrade |
promote |
yes | yes | yes |
dexaflow-runtime:py3.N, leoflow-runtime:py3.N |
development builds, the docs | promote |
no | yes | no |
| GitHub release marked Latest | install.sh without a pinned version, Renovate, Dependabot |
promote |
no | yes | no |
| Docs root | the docs site | docs-promote.yaml and cut-release.sh, after PUBLISHED |
no | yes | no |
The setup-dexaflow action (.github/actions/setup-dexaflow) is used at a
release tag of this repository (dexadata/dexaflow/.github/actions/setup-dexaflow@vX.Y.Z),
so the tag publishes it and it needs no job of its own.
verify-server-signature checks the dexaflow-cli signature, runs the image
(dexaflow version, python3, git, uv) and checks that it pulls without
credentials (scripts/release-check-public.sh: a warning on an rc, a failure
on a GA) before the gate.
GHCR can create a package private on its first push, and every check in
release.yaml runs logged in. After the first release that publishes a new
image (dexaflow-cli from v0.5.4-rc.1 on), make its package public before
the GA: the package's settings on GitHub, "Change visibility", Public. The
GA's verify-server-signature fails while dexaflow-cli is not public.
After an rc, as a follow-up and not a gate, check that the action resolves
its version from its ref: dispatch a workflow that uses
dexadata/dexaflow/.github/actions/setup-dexaflow@vX.Y.Z-rc.N with no
version input and runs dexaflow version. The docs pass version:
explicitly because github.action_ref is empty in some composite actions
(actions/runner#2473).
"Newest minor" means the tag is not older than any published GA
(scripts/release-promote-plan.sh, the same rule the docs root uses).
promote first checks with cosign that every image the chart and the moving
tags reference exists and carries this repository's release signature
(scripts/release-verify-images.sh), then pushes, signs and verifies the charts, moves
the py3.N tags and marks Latest last. Every step is idempotent: a failed
promote is recovered by re-running that job alone.
Every un-draft, the gate's and the one cut-release.sh does on a flake, passes
--latest=false: GitHub marks a release Latest by default when it leaves draft,
which would move the pointer before promote ran.
helm-release.yaml no longer runs on tags. Dispatch it from a release tag only
when the release run can no longer be re-run and a chart has to be published
again; it runs the same image check first.
The cut is a transaction with an irreversible middle. Once the prepare PR is
merged, main carries the bump and the release/<tag> branch is gone — so a
failure at the merge-commit gate (which is how a cut fails: the tag gate
refuses a run that did not finish) cannot be recovered by re-invoking, because
the re-cut guard sees main already carrying the version and cannot tell an
interrupted cut from an accidental re-cut of a released one.
scripts/cut-release.sh <version> --resume
picks up at the merge-commit gate, on the same base branch the cut used. It
refuses unless the base's Chart.yaml
carries exactly the version being cut, which is what proves the prepare half
completed, and it tags the commit that introduced that version rather than
main's tip — a chart version is a plateau, so anything merged since the
prepare would otherwise be swept into the tag. When they differ it prints what
it is excluding. There is deliberately no override for the version check: fix
the reason the gate refused, then resume.
Compose it with --dry-run first. It prints the sha it would tag and every
commit it is excluding, so you can check both by eye before anything is pushed:
scripts/cut-release.sh <version> --resume --dry-run
It is a preview of the target, not a full preflight — it exits before the
working-tree and on-main checks, so a dirty tree still passes it and fails
the real run.
--resume refuses a shallow clone outright: git rev-list is truncated there,
and at depth 1 it would silently return main's tip. Run git fetch --unshallow.
website/scripts/ci/versions.json's latest entry is the ref the published
site root is built from. A GA repoints it and archives the one it replaces, in
its own PR to main after the tag exists, because the deploy checks the
tag out. scripts/docs-promote.sh <tag> --pr makes that PR, titled
docs: publish <tag> at the documentation root from the branch
docs/promote-<tag>: versions.json repointed (label <tag> (latest)), the
[[params.versions]] menu of website/hugo.toml rendered from it, and the
README install pins bumped. It never takes the root for an rc or for a GA
older than the one the root serves, it works in a scratch worktree of
origin/main, and re-running it finds the same branch and PR. If the PR does
not land, the release is still published; the docs root just still serves
the previous GA until it does.
Two things run it (#1935):
- The Docs promote workflow (
.github/workflows/docs-promote.yaml), when the Release workflow of a tag push completes green, which includes itspromotejob. It enables auto-merge on the PR. This is what moves the root when the tag was pushed without the cut, as v0.5.4 was. For a patch GA the same run also opens the changelog sync pull request (How the changelog flows, step 5). cut-release.sh, after it watched the release to PUBLISHED. It opens the PR or finds the workflow's, waits for it green and merges it.
A PR opened or pushed with the workflow's default GITHUB_TOKEN starts no
workflows, so its checks never run. Set the DOCS_PROMOTE_TOKEN repository
secret (a fine-grained token with Contents and Pull requests read and write
on this repository) and the workflow uses it instead. Without it the workflow
warns, and a maintainer closes and reopens the PR to start its checks.
To promote by hand, for example when the release workflows were re-run after
the cut gave up: run the Docs promote workflow with the tag
(gh workflow run docs-promote.yaml -f tag=vX.Y.Z), or from any checkout
scripts/docs-promote.sh vX.Y.Z --pr --fetch. Do not edit versions.json by hand: the promotion also renders
hugo.toml and bumps the README, and a PR without them fails
check-docs-version-menu.sh and check-stale-versions.sh.
The tag + push (the irreversible publish) never happens without an explicit
confirmation: the interactive prompt (type the tag back), or --yes passed
deliberately for automation. Everything before the tag — prepare, PR, merge — is
recoverable; nothing is published until you confirm.
GitHub Actions has no job priority: release jobs wait behind every pull request run already queued. The v0.5.3 GA was tagged during a wave of 245 PR runs and its jobs waited about 50 minutes for a runner (#1685).
- Freeze PR pushes while a release publishes. From the go-ahead to publish
until the release run is green, nobody pushes to pull request branches or
opens new ones. Pushes to
mainandrelease-X.Ythat the cut itself makes are the exception. - The cut checks the queue before the tag. Right before the confirmation
prompt it counts the queued and running workflow runs of the repository and
refuses above
CUT_MAX_QUEUED(default 25). Wait for the queue to drain and re-run with--resume, or pass--force-queuewhen the release cannot wait.--dry-runreports the same counts. - The watch clock starts when a job starts.
CUT_WAIT_DEADLINE(default 5400 s) counts from the first release job that got a runner, not from the tag push, so a long queue no longer skips the docs root promotion. The time spent queued is logged asqueue_wait_sin.release-<tag>.logand has its own cap,CUT_QUEUE_DEADLINE(default 14400 s).
# Cut a release candidate (interactive confirm before the tag)
scripts/cut-release.sh v0.4.4-rc.1
# Promote it to GA once the RC is validated in staging
scripts/cut-release.sh v0.4.4
# What main carries that the patch would miss (the cut refuses while non-empty)
scripts/release-gap.sh 0.5.1 --fetch
# User-facing changes the release would ship without docs (the cut refuses while non-empty)
scripts/docs-gap.sh 0.5.2 --fetch
# Does main carry the changelog of every patch already tagged? (a minor cut refuses while not)
scripts/changelog-sync.sh --check-all --fetch
# Publish a GA at the docs root by hand (the Docs promote workflow and the cut do this)
gh workflow run docs-promote.yaml -f tag=v0.5.4
scripts/docs-promote.sh v0.5.4 --pr --fetch
# Bring a patch's changelog back to main by hand (the cut does this after a patch GA)
scripts/changelog-sync.sh 0.5.4 --pr --fetch
# A patch of a minor that already shipped is cut from release-0.5
scripts/cut-release.sh v0.5.1-rc.1
# Preview the plan — no branch, commit, PR, or tag
scripts/cut-release.sh v0.4.4 --dry-run
# Tag even though the Actions queue is busy (the cut refuses by default)
scripts/cut-release.sh v0.4.4 --force-queue
# Non-interactive (skips the confirm prompt — use only in trusted automation)
scripts/cut-release.sh v0.4.4 --yes
# Mutation floors on the release branch before a candidate (ADR 0101)
gh workflow run mutation.yaml --ref release-0.5
# Pure-logic self-test (no network) — also runnable in CI
scripts/cut-release.sh --self-testCut vX.Y.Z-rc.1 first, let the field validate it (especially the upgrade path
and any security-sensitive change), then promote to vX.Y.Z. Before the GA,
run the GA rows of test/release/perf-reliability-checklist.md and, on a real
cluster, test/release/rc-cluster-validation.md. Today the GA tag
rebuilds and re-runs the full gate matrix; lightening that to a build-once /
promote-artifact model is tracked in #878.
- PR/main red on a non-flake — the script stops before tagging; inspect the run, fix, re-run the command (it refuses if the tag already exists).
- Release retracted to a draft — the script un-drafts before classifying
the failure, then re-runs on a flake. That ordering is the fix for #979: the
un-draft used to sit inside the flake branch, and a retracted release makes
most smokes fail on the asset download instead — a failure that matches
nothing in
FLAKE_RE, soisflakewent to 0 and the un-draft never ran. The deadlock defended itself. If you are watching from the Actions UI rather than drivingcut-release.sh, the gate's step summary prints the two commands:gh release edit <tag> --draft=false, then re-run the failed jobs once the transient clears. - The cut stopped saying "non-flake" but the failure looks transient — check
whether the failed job died in
Initialize containers. That step has no log forgh run view --log-failedto return, so the flake classifier used to read the empty output as "no flake pattern matched". It now falls back to the job logs API and treats a still-unreadable log as unknown, which reruns rather than stops (#978, #1007). promotefailed (a registry blip, an image that is not signed) — the gate already passed, so nothing needs retracting: the release is public but not Latest, and no chart was pushed. Fix the cause and re-run the failedpromotejob. The job summary says which pointers it moved, and why it left Latest andpy3.Nalone for an rc or an older minor.- The docs root still serves the previous GA. Nothing about the release
fails when the root is left behind, so a separate job watches for it:
.github/workflows/docs-root-lag.yamlrunsscripts/docs-root-lag.shdaily and on every change toversions.jsononmain. It fails when thelatestleg names a different tag from the GitHub release marked Latest (which onlypromotemoves, by the rule ofscripts/release-promote-plan.sh), once that release is more than six hours old, and keeps one issue open, titled "docs: the documentation root does not serve the newest release", until the root catches up (#1936). Promote the release as Publishing the docs root says. no merge basein the heavy-E2E gate — rebase the branch onto currentmain(tracked: #876).
Related: #878 (light promotion gate), #862 (draft fragility), #876 (merge-base gate).