Skip to content

Versioning Guide

Automated semantic versioning for BoB-managed projects — conventional-commit-driven version bumps, git tags, CHANGELOG.md, and GitHub Releases. This is the encyclopedic reference for the BoB-wide versioning capability (capability #275).

TL;DR — Add "versioning": { "enabled": true } to your project manifest and run bob provision refresh. Commit with Conventional Commit messages. Merges to your main branch now cut versioned releases automatically.

Before this, releases across the fleet were manual or absent: bigbrain itself had a single stale v1.0.0 tag and a 0.0.0 package.json. Versioning makes releases declarative, reproducible, and consistent across every project — provisioned from one source of truth rather than hand-copied CI.

BoB standardizes on commit-and-tag-version (the maintained fork of the deprecated standard-version):

  • Minimal dependencies, language-agnostic, runs anywhere Node runs.
  • Reads Conventional Commits → computes the next semver → bumps package.json, regenerates CHANGELOG.md, and creates a git tag.

Alternative considered — release-please (Google): a release-PR model that avoids pushing release commits directly to the main branch (better when the main branch is protected). Deferred because it is less “automatic” (requires merging a release PR) and heavier to set up. Revisit if branch protection on the main branch becomes a hard fleet-wide requirement. See capability #275 for the full decision. BoB’s own engine now lands through a pull request where a project’s main branch is protected for admins (#3719), without adopting release-please: see a protected main branch.

A stable release is the same five steps everywhere; what differs is when they run:

  1. commit-and-tag-version bumps the version from the Conventional Commits since the last vX.Y.Z tag, rewrites CHANGELOG.md, commits chore(release): X.Y.Z [skip ci], and tags vX.Y.Z.
  2. The commit + tag are pushed atomically with --follow-tags (bounded retry).
  3. A GitHub Release is published from the new CHANGELOG.md section.

Provisioned projects (the templates/versioning/auto-release.yml that bob provision refresh installs) run those steps on every push to the main branch, scoped to the project’s source paths — see Trigger paths.

The write token stays out of reach of dependency installs (#3646). Both workflows check out with persist-credentials: false, install with npm ci --ignore-scripts against a tracked package-lock.json (every transitive package pinned, no lifecycle script run), and hand the token to git only inside the step that pushes. A provisioned project therefore needs a committed package-lock.json: npm ci refuses to run without one.

BoB itself stopped releasing per push (#2045 — 363 release commits a quarter was the cost). Its .github/workflows/auto-release.yml has no push trigger; it runs one engine, scripts/release/release.sh, in two channels plus a promotion verb:

Channel What lands When
stable the three steps above — bump, vX.Y.Z tag, CHANGELOG, GitHub Release workflow_dispatch with channel: stable (and an optional version_bump)
promote a stable release built at the newest nightly’s SHA (gates and merge route) workflow_dispatch with channel: promote (the operator’s blessing, no age gate), or bob release promote from a shell. There is no schedule (#3171): machines pinned to stable deploy what a stable release holds, so a stable release is the operator’s decision. The farm’s bob tick runs promote --auto only when the operator’s profile sets bob_upstream.auto_promote: true
nightly a lightweight nightly-YYYY-MM-DD tag on HEAD — no bump, no commit, no CHANGELOG entry, no GitHub Release the farm’s bob tick (#2054), or workflow_dispatch with channel: nightly

A nightly is cut only when master moved since the last nightly-* tag and the latest ci.yml run for that SHA is green (gh run list); it is idempotent for the day (a second run, or an unmoved master, is a stated no-op). A HEAD whose code CI did not test — a [skip ci] release commit, or a docs-only change, which ci.yml runs without its code lane (#3717) — is judged by its nearest ancestor that changed code; the tag still lands on HEAD. [skip ci] in the subject is not enough by itself: the commit must change release files alone (CHANGELOG.md, package.json, package-lock.json, docs/releases/). A [skip ci] commit that changes anything else had no CI, so the nightly is refused and names the commit and the files (#3630). Stable ignores nightly-* tags when it measures what is pending, so a nightly never masks a release. bob release --channel <c> runs the identical engine from a shell (local delivery runbook).

The chore(release) commit carries [skip ci], set by releaseCommitMessageFormat in .versionrc.json. GitHub honours that marker for every workflow on the push, which is what keeps the release commit from re-running CI (#1693) — and, for provisioned projects still on the per-push template, the if: guard on the head-commit subject stops the release loop. The commit only bumps package.json and regenerates CHANGELOG.md — no source surface, so a full suite run decides nothing. The subject still begins with chore(release), which is the prefix the commit-msg hook and scripts/release/release.sh (its resume detection) match on, so the marker is invisible to both.

Under a branch protection rule that binds admins, GitHub refuses the atomic push above: no check ran on a [skip ci] release commit. When the project’s manifest declares that rule (_meta.branch_protection), bob release and bob release promote land the release commit through a pull request instead, and drop [skip ci] for it. The engine pushes release/vX.Y.Z, opens the pull request, and waits for merge-on-green to fast-forward the main branch once the required checks pass. Then it tags the commit and publishes the Release. A failed check leaves nothing behind: no tag, the pull request closed, the branch deleted. On GitHub Actions the route needs the RELEASE_TOKEN secret and the RELEASE_VIA repo variable. The full path: How a release lands on a protected branch.

Commit type Bump Appears in changelog under
fix: patch Bug Fixes
feat: minor Features
feat!: / BREAKING CHANGE: major ⚠ Breaking
perf: patch Performance
refactor:, docs:, test:, build:, ci: patch* their sections
chore:, style: none hidden

* Non-feat/fix types contribute to the changelog but only bump when present with other changes; a release with only chore: commits is a no-op.

Versioning is an opt-in manifest capability (it is not a symlinked registry item — bob provision only symlinks; versioning copies template files).

provisions/<project>.json:

{
"_meta": { "project": "my-app" },
"skills": [],
"commands": [],
"agents": [],
"versioning": {
"enabled": true,
"tool": "commit-and-tag-version",
"main_branch": "main",
"node_version": "22",
"trigger_paths": ["src/**", "package.json"]
}
}

All fields except enabled are optional:

Field Default Meaning
enabled — (required) true provisions versioning; false (or omitted block) is a no-op
tool commit-and-tag-version version-bump engine (only value supported today)
main_branch main branch the workflow triggers on
node_version 22 Node version in the release workflow
trigger_paths (none) push path filter; omitted ⇒ any push to main_branch

The orchestrator populates this block for you (see below); you rarely hand-write it.

Reconcile the project from its manifest — both forms do the same thing:

Slash command:

/provision

Terminal:

Terminal window
bob provision refresh

provision_versioning() copies the template set into the project, never clobbering existing files, and idempotently merges package.json:

Template Destination
templates/versioning/auto-release.yml .github/workflows/auto-release.yml
templates/versioning/.versionrc.json .versionrc.json
templates/versioning/commitlint.config.cjs commitlint.config.cjs

package.json gains a release script + the commit-and-tag-version devDependency, and a 1.0.0 version if it was absent or 0.0.0.

Slash command:

/provision status
/provision diff

Terminal:

Terminal window
bob provision status # shows which versioning files are present / missing
bob provision diff # dry run — what a refresh would create (writes nothing)

versioning is recommended by default for every project (the registry command versioning is default: true) — fulfilling the “all BoB projects should include versioning” goal of capability #275. When you run the orchestrator interview (or scripts/orchestrator/recommend.js), the generated manifest includes a versioning block with trigger_paths chosen for your project type.

The push path filter is parameterized per project type so docs-only or unrelated changes don’t cut releases:

Project type trigger_paths
cms (Drupal, …) docroot/**
api, backend, web-app, library src/**, package.json
cli src/**, bin/**, package.json
framework (BoB-like) skills/**, commands/**, agents/**, registry/**, runbooks/**, scripts/**, hooks/**, bin/**, templates/**, schemas/**, profiles/**, Makefile, package.json
research, mixed, any (none — any push to the main branch)

The mapping lives in VERSIONING_TRIGGER_PATHS in scripts/orchestrator/recommend.js. An existing versioning block in a manifest is always preserved on re-run, so opt-outs and customizations survive.

Automated bumping only works if commits follow the convention — a non-conventional subject makes the release a silent no-op or a mis-bump. Enforcement is built into BoB’s commit-msg git hook (templates/git/hooks/commit-msg, installed by bob init), which does two jobs in order (git allows only one commit-msg hook):

  1. Strip AI boilerplate (Co-Authored-By: Claude, 🤖 Generated, …) — always.
  2. Enforce Conventional Commits — only when the project has versioning config (.versionrc.json or commitlint.config.* in the repo root). Projects without versioning keep the strip-only behavior, so nothing else is affected.

The enforcement step prefers node_modules/.bin/commitlint (using the provisioned commitlint.config.cjs) when installed, and otherwise falls back to a built-in, dependency-free check — so the convention is enforced even before npm install. Issue-ref scopes (feat(#274): …), feat!: breaking markers, and merge/revert/chore(release) subjects are all accepted; the chore(release) commit that commit-and-tag-version creates passes cleanly and is loop-guarded in CI.

The commit skill already drafts Conventional Commit messages, so commits made through /commit (or warp-drive) pass enforcement by construction.

bob release [--channel stable|nightly] [--dry-run] [--release-as patch|minor|major] [--allow-stale-docs] (and bob release publish vX.Y.Z — when the GitHub Release does not publish) (make release-local / make release-nightly) runs scripts/release/release.sh — the file auto-release.yml runs — from a workstation or the farm. It is the no-Actions fallback (#1691: billing cap, outage) and the farm’s nightly path (#2054). Both channels refuse on a dirty tree, a non-main branch, or an out-of-sync HEAD (exit 3 with the reason); stable is idempotent — re-running resumes a half-landed cut instead of double-bumping; nightly is idempotent per day. --dry-run runs every gate and writes nothing. The full local delivery path (CI gate, release cut, docs deploy) is the local delivery runbook.

Every release, on every channel, is graded by the release content gate (#2290) after the other gates and before any bump, tag or push. The tree about to be tagged must carry no operator data (the #2270 files, projects.json, session state, any secrets/ or memory/ directory), no client identifier from the operator profile’s tenants[], and no credential matching the shared secret-patterns.json catalogue. A hit refuses (exit 3) with the count and the command that lists the hits, and nothing is tagged:

bob-release: REFUSED — release content gate: 3 hit(s) in HEAD (1a2b3c4) would ship to every tenant — list them with: node …/release-content.js --ref 1a2b3c4 (or make check-release-content)

With no operator resolved the gate refuses too, because it cannot know which clients to keep out. Check before you release with make check-release-content. Fix a hit, or allow-list it in scripts/checks/release-content-allow.json with a why and the requirement that retires it. The rules, the allow-list and the output are in the release content gate reference.

Every stable release and every promote (#2554) asks whether the documentation still describes the code being released, and prints the answer as its own docs currency gate: line. The nightly channel has no docs gate. It reuses two existing checks rather than adding a new judgement:

Check What counts against the release Run it alone
Staleness grade (scripts/doc-keeper/staleness.js, #2552) each page under docs/ or runbooks/ graded stale: older than its Diátaxis mode’s stale_days, or past its expires date. Aging pages are counted but never hold make docs-check
How-to coverage (scripts/checks/check-howto-coverage.js, #2553) each bob command in scripts/bob/commands.json that no how-to or tutorial runs and that declares no reference_only reason make check-howto-coverage

The checks run on the tree being released. For bob release that is HEAD. For bob release promote it is the nightly’s SHA, even when master has moved past it. A tree with no scripts/bob/commands.json reports coverage n/a. The line names the stale-page count, each stale page with its last_reviewed date, and each uncovered command:

bob-release: docs currency gate: clear — 0 stale page(s) (10 aging), 0 command(s) lacking how-to coverage (warn-only: _workflow.docs_release_gate off)

Warn or hold is declared per project with _workflow.docs_release_gate, the same opt-in the ritual release gate uses. There is no separate policy mechanism.

  • Off (the default). A non-zero count prints docs currency gate: WARN — … and the release goes ahead. A check that cannot run prints UNJUDGED and the release also goes ahead.
  • On ("docs_release_gate": true under _workflow in .claude/settings.local.json). A non-zero count holds the release: exit 4, bob-release: HELD — docs currency gate: … is held — …, and nothing is bumped or tagged. A check that cannot run refuses (exit 3), so the gate never releases blind.

Check before you release. --dry-run runs the gate and prints the same line without releasing. When the gate holds, the dry run also exits 4. Run it ahead of a release to see what would hold:

Terminal window
bob release --dry-run # stable from HEAD
bob release promote --dry-run # stable from the newest nightly

To clear a hold:

  1. For each stale page, re-verify it against the code, then bump its last_reviewed date to today. The documentation standard sets the two-signal page-age rule and the staleness policy.
  2. For each uncovered command, write or extend a how-to that runs it. If a reference page really is the right home, declare "reference_only": "<why>" on its row in scripts/bob/commands.json.
  3. Re-run make docs-check check-howto-coverage, then bob release --dry-run, until the line reads clear.

Overriding a hold. --allow-stale-docs (stable or promote) releases over a held gate. The override is never silent. It is logged, and a decision issue naming what was released, the stale pages and uncovered commands, and the options is filed before anything is released. If that issue cannot be filed, the override is refused (exit 3). A dry run only states that it would record the decision. tests/test-release-docs-gate.sh drives every outcome against a fixture repo, with both checks stubbed through BOB_RELEASE_DOCS.

Every stable release has release notes, docs/releases/vX.Y.Z.md, written for the operator: Highlights, Fixed, Upgrade: every machine, Upgrade: every project, Action needed, Breaking changes, Known issues, and the full generated change list collapsed under Changes. Issue numbers appear only as trailing links, like “… (#123)”. The published notes are listed at Release notes.

  1. Draft them from source, on master, before you release:

    Terminal window
    bob release notes --draft

    Expected: drafted docs/releases/vX.Y.Z.md — N commit(s), M issue(s) since vA.B.C. The draft groups the commits since the last tag by issue, then by the issue’s area: label or parent capability, proposes the highlights, and fills the upgrade checklist: one block per machine role in the fleet manifest (always a laptop, dev-laptop, and a farm worker, farm-worker) and the bob ready step for every project. It is written with status: draft and a <!-- draft: … --> marker in every section you must finish.

  2. Finish it: rewrite the highlights in plain English, confirm the one-time steps, list known issues (or write “None known.”), delete every marker and set status: final.

  3. Check it, then commit it:

    Terminal window
    bob release notes check --release vX.Y.Z

    Expected: PASS docs/releases/vX.Y.Z.md (final). make check-release-notes runs the same check over every notes file; a draft may sit on master, but a final file must be complete.

  4. Release: bob release (or bob release promote). In a repo that keeps notes — it has a docs/releases/ directory — the release is refused (exit 3) while the notes for the version it is about to cut are missing, still a draft, or have an empty section; the refusal names each problem. Once they pass, the bump is forced to that version, and the notes file and the regenerated releases table are committed in the release commit. The GitHub Release body is the notes — with this fleet’s machines listed by role under “Upgrade: every machine” — and the generated CHANGELOG section collapsed below them, the list cut at whole lines when the body would pass GitHub’s size limit.

Hints that fill the draft. Declare them where the work is done, and the draft picks them up:

Hint Where Becomes
Release-note: <one plain sentence> a commit trailer the line for that commit’s issue
## Release note section the issue the commit names the line for that issue
Upgrade: <who>: <what>, then the command in backticks — e.g. Upgrade: farm-worker: install the tick timer `bob tick --install-timer` a commit trailer an Action needed row; <who> is machine, project or a role (farm-worker, …), and a role or project hint is also listed under that upgrade section

The notes file names machine roles, never machines, so the same page can be published.

What is unreleased. bob release notes --unreleased prints what is on the current branch since the last release tag, in the same plain form — what a machine on the checkout channel is running.

The public pages. bob release notes site builds the public form of every final notes file — an index newest first, a page per release at <section>/vX.Y.Z/, <section>/latest/ redirecting to the newest, and an Atom feed at <section>/feed.xml — as one site-publish/1 bundle for bob site publish, the one publisher to the operator’s site. The public form drops the frontmatter, the generated change list, every issue reference and every link that is not a public https page (the source repo is private). Before anything is written, every page is scanned for an operator literal, a machine name from the fleet manifest, an email address, a repository link or a project name from the operator’s manifests; a hit refuses (exit 3) and names the file and line.

  1. Declare the section public once, in the operator’s config/site-publish.json: "bob/releases": { "public": true, "producer": "bob release" }. The Access application gates only /bob/briefings, so the releases are served without a login.

  2. Preview, then publish:

    Terminal window
    bob release notes site --publish # report only: added / updated / unchanged / orphaned
    bob release notes site --publish --apply # commit, push, deploy, then fetch every page without credentials

    Expected: every probe line reads PASS <url> — 200, no login. A page that redirects to a Cloudflare Access login fails the probe (exit 3). bob release notes site --probe re-runs the check on its own.

The running version points at its notes. bob doctor’s version line ends with release notes: <url> for a deployed release (or unreleased work: bob release notes --unreleased on the checkout channel). bob update prints the target release’s notes URL and its Action needed table before it moves the checkout or deploys, so you see the one-time steps first. The URL is the public page when the operator declares a public releases section in config/site-publish.json, else the GitHub Release. bob release notes url vX.Y.Z and bob release notes show vX.Y.Z --section "Action needed" print the same from a shell.

Promoting a nightly to stable: bob release promote

Section titled “Promoting a nightly to stable: bob release promote”

A stable release is a promotion from a nightly, decided by a rule rather than a bot (#2057). bob release promote [--auto] [--allow-overdue <ritual>] [--allow-stale-docs] [--dry-run] picks the newest nightly-* tag (names sort by date) and runs its gates in order, each refusal exiting 3 with the reason (a promotion held by a ritual exits 4):

Gate Outcome
No nightly-* tag, or the newest is not on origin/master refused
The nightly is already inside a released vX.Y.Z on master no-op, exit 0 (a missing GitHub Release for that tag is published)
An open issue labelled both p1-critical and harness-defect refused, every such issue named; a failed gh issue list refuses too — the gate never promotes blind. The query skips the gh shim’s read cache (#3626), so while the forge is down promote refuses instead of trusting a cached list
A release-gating ritual is held (#2236) — the project set _workflow.ritual_release_gate: true and a ritual it declares with gates: ["stable-release"] is overdue (the release gate) held, exit 4, naming each ritual, why, and its run — the open run’s link, else the last run’s and bob ritual run <ritual>. Off by default (the gate is opt-in per project; ritual release gate: off is noted and no schedule is read). A schedule ritual.js due cannot produce refuses (exit 3) — never promote blind; it is read with the forge cache off, like the defect query
--auto only: _workflow.ritual_release_gate is off or cannot be read (#3627) refused, naming the gate — unattended promotion never runs without the ritual gate; promote by hand, or turn the gate on in the project’s manifest
--auto only: the nightly may be younger than 24 h refused
Docs currency (#2554): stale pages or bob commands without a how-to in the nightly’s tree always a named line; held, exit 4 only when the project set _workflow.docs_release_gate: true (--allow-stale-docs overrides it and records a decision issue)

Overriding a held ritual. --allow-overdue <ritual> (once per ritual) promotes over that overdue ritual. It is never silent: the override is logged, and a decision issue naming the nightly, the ritual, why it was overdue and the options is recorded before anything is released — an override whose issue cannot be filed is refused (exit 3). A dry run states it would record the decision and files nothing. Naming a ritual that is not holding changes nothing. The judgement itself lives in ritual.js due (one bob-rituals-due/1 document, judged against the newest released v* tag on master via --last-release), so the farm digest and the briefing read the same verdict the gate refused on.

How the 24 hours are counted. A lightweight tag records no time, so a nightly’s age counts from the end of its UTC day — the latest nightly-D can have been cut. --auto therefore never promotes a nightly that might be younger than 24 h, at the cost of waiting up to a day longer than the true age requires. Without --auto the age gate does not apply: that is Paul blessing a nightly from the digest.

What gets released. The stable flow runs at the nightly’s SHA, never at a newer master:

  • The nightly is master’s HEAD — the plain stable flow above: bump, CHANGELOG, vX.Y.Z on the release commit, atomic push, GitHub Release.
  • Master moved past the nightly — the release commit is built on the nightly SHA (so the bump and CHANGELOG cover exactly the nightly’s commits and vX.Y.Z^ is the nightly), then merged into master with a chore(release): merge vX.Y.Z into master [skip ci] commit and pushed atomically with the tag. A merge conflict or a push that fails three times rolls the local checkout back to origin/master and removes the local tag — nothing is stranded; re-run to retry. The next stable measures its pending commits from vX.Y.Z, so the commits after the nightly are released next time.

The announcement. Every GitHub Release the engine creates — plain stable, promote, or a resumed publish — sends one bob notify (#2048): the release name, its CHANGELOG section (truncated at 3000 characters), and bob update --channel stable. A bridge that is not configured prints the text instead (notify exit 3), and a failed send never fails the release. An idempotent re-run that finds the Release already published sends nothing.

The Release body is the version’s CHANGELOG.md section, read at the release tag. It is passed to gh release create as a file, so a long section never hits the operating system’s 128 KiB single-argument limit.

Long notes are capped, never dropped (#2557). GitHub refuses a release body over 125 000 characters. A section over that limit keeps its leading whole entries up to the limit. It ends with a note giving the full length, how many entries of how many were kept, and a link to the full CHANGELOG.md at the tag. The run prints publishing a capped body. v1.274.0’s 207 221-character section, for example, publishes 121 of its 207 entries in 123 543 characters.

A failed publish is PARTIAL, exit 5. If gh release create fails after the tag has been pushed, the run never exits 0. It prints bob-release: PARTIAL — the GitHub Release for vX.Y.Z was NOT published (…) after gh’s own error, then lists:

  • Landed: the tag is pushed, and the version is in package.json and CHANGELOG.md. Machines can already bob update to it, because the update resolves the newest tag, not the Release.
  • Not landed: the Release page and its bob notify announcement.
  • The finishing command.

To finish a partial release, or to backfill a Release that is missing for any other reason:

  1. Check what would be published:

    Terminal window
    bob release publish vX.Y.Z --dry-run
  2. Publish it:

    Terminal window
    bob release publish vX.Y.Z

    This creates the Release from the tag’s own CHANGELOG.md section, capped as above, and announces it once. It runs from any branch and does not bump, commit or tag. It refuses a tag that does not exist or is not on origin (exit 3), and it is a no-op when the Release already exists.

  3. Confirm the Release exists:

    Terminal window
    gh release view vX.Y.Z --json url,tagName

Re-running the original bob release (or bob release promote) also resumes a partial release, because a HEAD that is already the tagged release commit skips the bump and publishes. bob release publish is the one command that works whatever HEAD is. tests/test-release-publish.sh covers the capped body, the exit 5 and the backfill against a gh stub that rejects an over-limit body the way GitHub does. tests/test-release-promote.sh drives every gate against a fixture repo with gh, the notifier (BOB_RELEASE_NOTIFY) and the ritual engine (BOB_RELEASE_RITUALS) stubbed; BOB_RELEASE_NOW fixes “now” for the age gate.

Just want to get current? Run the Latest BoB is the short task version of this section.

A release is only useful once a machine runs it. bob update (#2046) is the one-command path from “a tag exists at origin” to “this machine’s BOB_HOME is that tag, and bob doctor says so”:

Terminal window
bob update # newest vX.Y.Z (stable — the default channel)
bob update --channel nightly # newest nightly-YYYY-MM-DD
bob update --to v1.273.0 # an explicit tag (its name records the channel)
bob update --dry-run # the tag it would move to; fetches, writes nothing
bob update --json # one bob-update/1 document on stdout

In order: the live-session guard (settings-hooks-reconcile.js live-check — the same #2040 verdict deploy.sh consults, never re-implemented; a live Claude Code session or active warp-drive run refuses the update with exit 4 naming each one, and there is no --force-live here — an update moves the checkout a running session may be working in. The refused update is queued instead (#3162): it is recorded in $BOB_HOME/.deploy-pending with its target, and BoB runs it itself when the last session ends — from the SessionEnd hook, bob tick or the fleet-snapshot timer (see Queued deploys)); then the tree gates — tracked changes or a checkout off its main branch refuse (exit 3; untracked files are carried, never touched); then git fetch --tags and the target — the newest tag of the channel by version order (v1.10.0 sorts after v1.2.0), or the --to tag; then a fast-forward only (git merge --ff-only) of the BOB_SOURCE checkout — a HEAD ahead of or diverged from the tag refuses (an update never rewinds or rebases; pick a newer tag, the nightly channel, or move the checkout by hand); then scripts/deploy.sh from the checkout the update landed on (so the tag’s own deploy runs, and writes the .bob-version marker); then bob doctor, whose exit is the command’s exit — 0 converged, 1 drift named. A checkout already at the tag still deploys, so a machine whose BOB_HOME fell behind converges without a new release. Idempotent: a second run is a stated no-op plus a converging deploy.

The source checkout stays the thing that is updated and BOB_HOME stays the deploy target: BOB_SOURCE resolves through bob-source-resolve.sh ($BOB_SOURCE → the engine’s own checkout → ~/projects/bigbrain), and a resolution that lands on the deploy target is refused (exit 2) — there is nothing to fetch in deploy output. tests/test-bob-update.sh drives the real engine against a tagged fixture remote (channel resolution, --to, dirty-tree and live-session refusal, marker contents, doctor’s exit passthrough).

A machine pinned to a release channel (#3171)

Section titled “A machine pinned to a release channel (#3171)”

A machine’s entry in the operator’s machines.json can declare where its BOB_HOME comes from:

{ "id": "macbook-pro-16", "role": "dev-laptop", …, "bob_channel": "stable" }
bob_channel BOB_HOME is deployed from
stable the newest local vX.Y.Z tag
nightly the newest local nightly-YYYY-MM-DD tag
checkout (or absent) the BOB_SOURCE working tree, as before

On a pinned machine scripts/deploy.sh makes a clean, detached worktree of the tag at $BOB_HOME/.source/<tag> and re-runs itself from there. The operator’s checkout can stay on master, on a branch or dirty, and BOB_HOME stays on the release. Every deploy path goes through deploy.sh: bob update, the host converge in bob warp start, the queued deploy and bob tick. None of them can deploy the working tree by accident. A pinned machine whose channel has no local tag is refused with exit 3, and nothing is deployed. To deploy the working tree on purpose, run deploy.sh --channel checkout. scripts/fleet/channel.js resolve prints the resolved channel.

bob update on a pinned machine fetches tags and deploys the channel’s newest tag. It never moves, fast-forwards or gates the checkout, so a dirty tree or a branch is no obstacle, and --to is refused. bob doctor and bob status measure the machine against its channel: behind compares the deployed commit with the channel’s newest tag, never the checkout’s HEAD, so unreleased commits on master never mark a pinned laptop behind. The version line names the channel, for example channel: stable v1.276.0 · v1.277.0 available. bob viz shows the same label in each machine’s header. “Available” means a tag this machine has fetched; bob update fetches.

bob_workload.bob_home_channel on a farm entry is a different thing: the channel bob tick releases and updates on. It is not a deploy pin, so a farm with no bob_channel keeps deploying its checkout.

Pinning a project to a version: bob_version

Section titled “Pinning a project to a version: bob_version”

bob update moves a machine; a project that must not move with it says so itself (#2047). An optional bob_version in the project’s dev.json pins the BoB it expects — a channel or an exact tag:

{ "bob_version": "stable" } // any vX.Y.Z deploy satisfies it
{ "bob_version": "nightly" } // any nightly-YYYY-MM-DD deploy
{ "bob_version": "v1.273.0" } // exactly that tag
{ "bob_version": "nightly-2026-09-10" } // exactly that nightly

bob ready (and bob preflight through it) compares the pin with this machine’s .bob-version marker in its version step: a channel pin needs the marker’s channel, a tag pin needs the marker’s tag. Anything else — including an untagged checkout deploy, which is exactly the drift the pin exists to refuse — is a blocker that names the fix:

BLOCKED version BOB_HOME runs 1.273.0 (v1.273.0) [stable], project pins tag v1.270.0 — fix: bob update --to v1.270.0
Fix: bob update --to v1.270.0

No pin means any deployed BoB is accepted. A machine with no marker (never deployed by a deploy.sh that writes one) reports the step not measured rather than failing — the project cannot be judged there, and that is not the project’s fault; the fix is a deploy, which the host step already demands. The value is pattern-checked by schemas/dev.schema.json and pinned by the committed fixture under make check-schemas. Step reference: bob ready — Steps.

The workflow pushes the release commit and tag directly to the main branch using the default GITHUB_TOKEN. If branch protection is enabled on the main branch without an exception for github-actions[bot], the push will fail. This is the trade-off behind deferring release-please (the PR-model alternative). No protection ⇒ it just works.

bigbrain itself is the reference implementation (#274): see its .github/workflows/auto-release.yml, .versionrc.json, and package.json release script. The capability was then generalized into the provisionable template set (#276) and wired into the orchestrator (#277).