Versioning Guide
Versioning Guide
Section titled “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 runbob 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.
The tool: commit-and-tag-version
Section titled “The tool: commit-and-tag-version”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, regeneratesCHANGELOG.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.
How a release happens
Section titled “How a release happens”A stable release is the same five steps everywhere; what differs is when they run:
commit-and-tag-versionbumps the version from the Conventional Commits since the lastvX.Y.Ztag, rewritesCHANGELOG.md, commitschore(release): X.Y.Z [skip ci], and tagsvX.Y.Z.- The commit + tag are pushed atomically with
--follow-tags(bounded retry). - A GitHub Release is published from the new
CHANGELOG.mdsection.
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.
On a protected main branch
Section titled “On a protected main branch”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.
Bump rules (Conventional Commits)
Section titled “Bump rules (Conventional Commits)”| 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.
Provisioning
Section titled “Provisioning”Versioning is an opt-in manifest capability (it is not a symlinked registry
item — bob provision only symlinks; versioning copies template files).
1. Declare it in the manifest
Section titled “1. Declare it in the manifest”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.
2. Reconcile
Section titled “2. Reconcile”Reconcile the project from its manifest — both forms do the same thing:
Slash command:
/provisionTerminal:
bob provision refreshprovision_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.
3. Inspect
Section titled “3. Inspect”Slash command:
/provision status/provision diffTerminal:
bob provision status # shows which versioning files are present / missingbob provision diff # dry run — what a refresh would create (writes nothing)Orchestrator integration
Section titled “Orchestrator integration”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.
Trigger paths
Section titled “Trigger paths”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.
Conventional-commit enforcement
Section titled “Conventional-commit enforcement”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):
- Strip AI boilerplate (
Co-Authored-By: Claude,🤖 Generated, …) — always. - Enforce Conventional Commits — only when the project has versioning config
(
.versionrc.jsonorcommitlint.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.
The engine from a shell: bob release
Section titled “The engine from a shell: bob release”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.
The release content gate
Section titled “The release content gate”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.
The docs currency gate
Section titled “The docs currency gate”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 printsUNJUDGEDand the release also goes ahead. - On (
"docs_release_gate": trueunder_workflowin.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:
bob release --dry-run # stable from HEADbob release promote --dry-run # stable from the newest nightlyTo clear a hold:
- For each stale page, re-verify it against the code, then bump its
last_revieweddate to today. The documentation standard sets the two-signal page-age rule and the staleness policy. - 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 inscripts/bob/commands.json. - Re-run
make docs-check check-howto-coverage, thenbob release --dry-run, until the line readsclear.
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.
Release notes (#3060)
Section titled “Release notes (#3060)”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.
-
Draft them from source, on master, before you release:
Terminal window bob release notes --draftExpected:
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’sarea: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 thebob readystep for every project. It is written withstatus: draftand a<!-- draft: … -->marker in every section you must finish. -
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. -
Check it, then commit it:
Terminal window bob release notes check --release vX.Y.ZExpected:
PASS docs/releases/vX.Y.Z.md (final).make check-release-notesruns the same check over every notes file; a draft may sit on master, but a final file must be complete. -
Release:
bob release(orbob release promote). In a repo that keeps notes — it has adocs/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.
-
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. -
Preview, then publish:
Terminal window bob release notes site --publish # report only: added / updated / unchanged / orphanedbob release notes site --publish --apply # commit, push, deploy, then fetch every page without credentialsExpected: 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 --probere-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.Zon 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 achore(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 toorigin/masterand removes the local tag — nothing is stranded; re-run to retry. The next stable measures its pending commits fromvX.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.
When the GitHub Release does not publish
Section titled “When the GitHub Release does not publish”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.jsonandCHANGELOG.md. Machines can alreadybob updateto it, because the update resolves the newest tag, not the Release. - Not landed: the Release page and its
bob notifyannouncement. - The finishing command.
To finish a partial release, or to backfill a Release that is missing for any other reason:
-
Check what would be published:
Terminal window bob release publish vX.Y.Z --dry-run -
Publish it:
Terminal window bob release publish vX.Y.ZThis creates the Release from the tag’s own
CHANGELOG.mdsection, 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 onorigin(exit 3), and it is a no-op when the Release already exists. -
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.
Updating a machine: bob update
Section titled “Updating a machine: bob update”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”:
bob update # newest vX.Y.Z (stable — the default channel)bob update --channel nightly # newest nightly-YYYY-MM-DDbob 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 nothingbob update --json # one bob-update/1 document on stdoutIn 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 nightlybob 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.0No 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.
Known limitation: branch protection
Section titled “Known limitation: branch protection”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.
Reference implementation
Section titled “Reference implementation”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).