Release Process
Shipping a base/base release means driving two workflows by hand and letting the rest fire on their own. You start a release, let release candidates accumulate on the branch until one looks right, then publish that version as the final tag.
The version number lives in Cargo.toml on the release branch, and almost every guard rail in the pipeline keys off it. A branch whose Cargo.toml still reads 0.0.0 has not been initialised yet, and the automation declines to build from it.
Step by step
Section titled “Step by step”1. Start a release
Section titled “1. Start a release”Go to Actions → Start Release → Run workflow and pick a bump type — major for breaking changes, minor for a feature release, patch for fixes.
You do not supply a version. The workflow derives the next one from the most recent final tag and cuts a releases/vX.Y.Z branch for it. Where that branch is cut from depends on the bump: a patch branches off the newest matching releases/vX.Y.* branch, while major and minor branch off main.
Creating the branch sets off Release Version Sync, which raises a PR writing the new version into Cargo.toml.
2. Merge the version sync PR
Section titled “2. Merge the version sync PR”Nothing else can proceed until that PR lands. While Cargo.toml reads 0.0.0, RC builds keep declining to run. Review it and merge it into the release branch.
3. Collect release candidates
Section titled “3. Collect release candidates”From here the branch builds itself. Create RC runs on every push to it and:
- Backs out quietly when
Cargo.tomlis still0.0.0, which means step 2 has not finished. - Works out the next number in the RC series and tags it —
v0.6.0-rc.1, thenv0.6.0-rc.2, and so on — against the commit that triggered the run. - Hands that tag to Build RC as a separate dispatch, then reports success without waiting to see what the build does.
Build RC is what actually produces the artifacts, delegating to Build Release for multi-arch Docker images and native binaries. The compatibility node image and each of the single-binary images all receive the RC tag and nothing else; latest stays where it is until a final release. Because each candidate builds inside its own run, a fresh merge no longer cancels or queues behind the artifacts of the candidate before it — runner capacity permitting, the two build side by side.
That split is worth internalising before you read a green checkmark. A passing Create RC tells you a tag was allocated and a build was requested, and nothing more. Artifact success lives in the Build RC run, so that is the one to open when you want to know whether a candidate is installable.
Need another candidate? Push another commit. Fixes and backports both re-trigger the workflow, so there is no separate command for cutting an RC.
4. Publish the final release
Section titled “4. Publish the final release”When a candidate holds up, run Actions → Publish Release → Run workflow and type the bare version — 0.6.0, with no v and no releases/ in front of it.
Before doing anything, the workflow confirms the release branch is really there and that Cargo.toml has moved off 0.0.0. Then it:
- Tags the release branch
vX.Y.Z. - Builds seven images under
PROFILE=maxperf, each for both amd64 and arm64: the compatibilitynodeimage plus the single-binarybase,base-reth-node,base-consensus,base-builder,basectl, andbase-snapshotterimages. - Gives every one of them the same four tags:
vX.Y.Z,X.Y,X, andlatest. - Drafts a GitHub release, writes its changelog automatically, and attaches the binaries.
The draft is the last manual gate. Read it over on GitHub and publish it yourself.
Which image to pull
Section titled “Which image to pull”Every image lands under the ghcr.io/base/ namespace, and the names are easy to confuse. The one to watch is base.
| Image | Bake target | What it holds |
|---|---|---|
ghcr.io/base/node | base | The compatibility image: base, base-reth-node, base-consensus, and snapshotter, plus supervisord, which is the default command and runs the execution and consensus entrypoints together. The root docker-compose.yml pulls this one. |
ghcr.io/base/base | unified | Only the base binary, with ENTRYPOINT ["./base"]. |
ghcr.io/base/base-reth-node | execution | Only base-reth-node. |
ghcr.io/base/base-consensus | consensus | Only base-consensus. |
ghcr.io/base/base-builder | builder | Only base-builder. |
ghcr.io/base/basectl | basectl | Only basectl. |
ghcr.io/base/base-snapshotter | snapshotter | Only snapshotter. |
The bake target called base publishes as node, while the image called base comes from a different target. So ghcr.io/base/base is not a renamed ghcr.io/base/node. Swap one for the other and the container starts a lone ./base binary, with no supervisord and none of the entrypoint scripts the operator default relies on.
The split arrived after v1.4.1-rc.11 was cut, so no tag published so far carries the single-binary images. Until a release built from it ships, ghcr.io/base/node is still the only image path you can pull.
Each image is published on its own, so one failure does not stop the rest. If neither architecture builds for an image, the workflow skips that image with a warning. If only one builds, it publishes that one with a warning. A final release can therefore exist on one image path but not another, or exist as amd64 only. Before you pin a tag on arm64 hardware, check that the manifest actually lists your platform:
docker buildx imagetools inspect ghcr.io/base/node:vX.Y.ZWhy RC builds sometimes do nothing
Section titled “Why RC builds sometimes do nothing”Create RC is wired to every push on any releases/v* branch, including pushes that arrive before the version sync PR merges. Those are harmless. Rather than failing the run, the workflow spots the 0.0.0 version and exits with a notice.
RC numbering needs no bookkeeping either. N in vX.Y.Z-rc.N is derived from the tags already present.
Only the tagging and dispatch half is serialised per release branch, and it is serialised deliberately rather than throttled. The tagging job runs under queue: max, which lets as many as 100 runs sit in line instead of displacing one another. Setting cancel-in-progress: false on its own does not achieve that — pending runs still get replaced. Two caveats come with the queue. GitHub releases waiting jobs in the order they began waiting, which is not guaranteed to be commit order, though each run still tags the commit that started it. And once the line is full, further runs are cancelled outright.
The dispatch is explicit for a reason that is easy to trip over when editing these workflows. A tag pushed under GITHUB_TOKEN will not set off another workflow, so Create RC cannot simply push the tag and expect a build to notice. It calls the build directly instead, which is why it carries actions: write. The permissions needed to publish artifacts are not granted there; they stay with Build RC.
Retrying a candidate build
Section titled “Retrying a candidate build”Which half failed decides what you rerun.
If the tag was created but the dispatch did not go through, rerun Create RC. It recognises the tag it already made at that commit and reuses it rather than burning the next number in the series.
If the tag is fine and the artifacts failed, rerun the Build RC run itself, or start one against the existing tag:
gh workflow run build-rc.yml --ref v1.4.0-rc.1Build RC only accepts an RC tag as its ref. Pointing it at a release branch or a final tag will not work. Note also that dispatching a tag that is already building starts a second build rather than replacing the first, so check before you fire one off.
Rolling out the workflow change
Section titled “Rolling out the workflow change”Landing this split across branches has an ordering requirement that is easy to get backwards. GitHub will only dispatch a workflow that exists on the default branch, so Build RC has to merge to the default branch first. Only then do you backport the workflow and release-script changes to each release branch still in service.
Both halves matter because dispatch happens at the RC tag. The commit under that tag has to contain build-rc.yml and the reusable build workflow too, otherwise there is nothing there to run. Tags cut before the split therefore cannot be dispatched this way at all; recover those by rerunning the build jobs they originally had.
Two things this rollout does not do. Updating the default branch alone leaves push-triggered workflows on existing release branches exactly as they were. And runs already queued or in flight keep the configuration they started with, so the change will not retroactively rescue cancelled runs or free the concurrency locks they are holding.
There is one trap in older runs specifically. Before touching a legacy Create RC run, look at which ref it checks out. Earlier definitions checked out the release branch rather than the triggering commit, and since the branch keeps moving, a rerun can tag a newer commit than the one you were trying to recover.
Workflow reference
Section titled “Workflow reference”| Workflow | Trigger | What it does | Output |
|---|---|---|---|
| Start Release | Manual — bump type | Cuts the release branch from the computed next version | releases/vX.Y.Z branch |
| Release Version Sync | Automatic — branch creation | Raises the PR that writes the version into Cargo.toml | PR targeting the release branch |
| Create RC | Automatic — push to releases/v* | Tags the next candidate and requests its build | RC tag + a dispatched build |
| Build RC | Dispatched by Create RC at the RC tag | Checks the ref is an RC tag, then builds on its own schedule | Docker images + binaries |
| Build Release | Called by Build RC and Publish Release | Shared build step behind both paths | Docker images + binaries |
| Publish Release | Manual — version number | Cuts the final tag and assembles the release | Final tag + Docker images + draft GitHub release |
Build Release is the only entry here you never invoke directly. Both the candidate path and the publish path delegate their image and binary builds to it, so a final release and the candidate it came from are produced by the same build steps.
Both paths build with maxperf, not the release default. Build Release compiles every native binary archive under it and passes it through to the image bake, so candidates and final releases are optimized identically. It layers fat LTO and a single codegen unit over release, which is why these builds take considerably longer than one you run yourself. See build profiles for the full settings.