Documentation for v2.10.0. Read the latest, v2.12.0.

Developer

Release graphs

The dev graph is everything any build ever recorded. To know what production is, a build declares its stream: trunk builds maintain the main graph, and a releaser cuts named copies of it, sanshain-branches, that hotfix builds keep up to date.

What changed in 2.6.0

  • A branch cut emits the branch.cut webhook event to global hooks, and a require that records a new edge in a stream emits dependency.recorded with that stream's name.
  • The graph's node panel shows a system's catalog context in every view; the periphery of a branch is still what trunk had when the branch was cut.

The three streams#

Every provide, require and bundle may carry one stream marker; the catalog push takes only trunk:

Streams never change version rules or what a require is answered; they decide which graph records the call. A GA backport provided from a release branch without trunk is a normal GA version that trunk does not claim.

The graph never fails a build. An unknown tag, or trunk and tag together, still succeeds: the spec is stored and served, the dev graph records the activity, trunk and every branch stay untouched, and the response carries a Sanshain-Warning header:

Sanshain-Warning: no sanshain-branch 'release-maribu' - this build was not recorded in a release graph
Sanshain-Warning: trunk and tag are mutually exclusive - this build was recorded in neither

Branches are never auto-created, so a typo cannot mint a half-empty release graph; it surfaces as a printed warning, and as a failed build in strict mode. A retire keeps its own rule: trunk or tag alongside retired: true is a 400.

Trunk data ages, visibly#

A Producer that was quietly scrapped, or a Consumer that keeps a require it no longer uses, is a forgotten thing, and the main graph's job is to show that rather than present stale data as current. Trunk provides and requires carry a last-seen timestamp with their own TTL, trunk_max_age_days (default 90, independent of the dev window). Entries past half the TTL are highlighted as stale in the main view (the amber dashed edge); past the TTL they leave the current main graph. They stay in history: the TTL closes an entry, it never deletes it.

The timeline#

The main graph's timeline and a branch cut from it Trunk builds append markers to the main graph's timeline: a pin recorded, a version provided. A releaser cuts the sanshain-branch release-maribou at one of those instants, and can place the cut at an earlier instant with as_of. From the cut on, hotfix builds that send tag: release-maribou append to the branch's own timeline, while trunk keeps moving. Compare diffs the branch's current state against main's. main graph · fed by trunk builds web-shop pins orders 1.4.0 trunk provides orders 2.0.0 release cut POST /admin/branches trunk provides payments 2.1.0 now as_of: the cut can be placed at a past instant release-maribou a sanshain-branch: main copied at the cut hotfix build with tag: release-maribou orders 2.0.1 another hotfix compare main vs branch Markers are the instants a graph changed. Writes append; nothing here is ever rewritten.
Trunk builds feed main; a releaser cuts a branch at an instant, past or present; tagged builds feed the branch.

Trunk and branch writes are append-only: "last write wins" is how the current graph is read, not how it is stored. So the main graph and every branch can be rendered as they were at any past instant, which the timeline slider on the graph page does; its markers are the instants the graph changed. History is unbounded by default and is only ever read.

Cutting a branch#

A release cut is a copy of the main graph, or of another branch, at a chosen instant. It needs the releaser role; the admin page, the graph page's Create branch here on the timeline, or the release pipeline does it:

Request

curl -X POST $SANSHAIN/admin/branches \
  -H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
  -d '{"name": "release-maribou"}'
# optional: "source": "<another branch>", "as_of": "2026-10-01T00:00:00Z"

source defaults to trunk, as_of to now. A past as_of is a first-class path: releases are cut from protected branches and carry no code drift, so trunk-at-cut-date is the release whenever someone gets around to naming it. The branch is complete by construction, because the main graph holds every service regardless of recent build activity, and it takes the periphery trunk had at that instant.

Names are unique among live branches; main, dev and trunk are reserved, because the first two name the built-in views and the third is the stream a trunk build records. Renaming and deleting a branch are administrative acts: a rename keeps membership, timeline and audit history (a pipeline still sending the old name gets the warning above until it is reconfigured); a delete frees the name.

The /admin/branches routes are session-authenticated, so a pipeline cuts a branch with a session token from POST /auth/login; an API token is refused there. Every other call of a release pipeline (the tagged provides and requires) uses the API token as usual.

Branch membership on the Producer page#

A version row shows chips naming every branch that references the version, next to the trunk badge:

A version row with the GA badge, the trunk badge and chips naming the branches that reference it
Which releases contain this version, at a glance.

Branches reference versions by value, never by row id. Deleting a version leaves a visibly dangling reference in every branch that pins it (the orange dotted edge), and a later re-provide of that number heals it. Deleting a Producer or Consumer that a branch's recorded graph references is refused with 409 naming the branches, so a release cut that already happened is never silently rewritten.

Reading a branch#

A sanshain-branch's graph, including a hotfixed pin and a dangling reference
A branch's graph with a hotfixed pin and a dangling reference.

Setting it up in CI#

Trunk CI sets SANSHAIN_TRUNK=true (or the client's --trunk, -Dsanshain.trunk=true); release and hotfix pipelines set SANSHAIN_TAG=<branch>. Nightly re-runs of an unchanged trunk build are wanted. The CI integration chapter has the pipeline shapes.

Checked against v2.6.0.