Skip to content

Channels, promote, rollback

Patchline’s storage has three layers, only one of which is mutable:

objects/sha256/ab/cd/<hash> ← immutable, content-addressed
releases/<version>/manifest.json ← immutable snapshot of a version
channels/<channel>/manifest.json ← MUTABLE pointer, rewritten by promote/rollback

Think of it as Git: objects/ is the object database, releases/<v>/manifest.json is a commit, channels/<c>/manifest.json is a branch ref.

Channels are pointers

A channel manifest is structurally identical to a release manifest. It just lives at a mutable path. When a client runs apply --channel stable, it fetches channels/stable/manifest.json, verifies the signature, and reconciles its local install against the file list.

Channels are independent of each other. beta can be on v1.2.3 while stable sits on v1.2.2 — each maintains its own monotonically-increasing release_sequence.

Publish vs. promote

CommandWhat it doesRe-uploads bytes?
publishScans, hashes, uploads new objects, writes a new release manifest, sets the channel pointerOnly new/changed objects
promoteReads an existing release manifest, points a channel at itNever
rollbackSame as promote — points a channel at an existing release manifestNever

promote and rollback are the same operation under the hood (releaseops.moveChannel). The verb is operator-facing; the storage effect is identical.

How rollback knows the old state

It doesn’t need to know — the old release manifest is still sitting at releases/<old-version>/manifest.json, and the objects it references are still in objects/sha256/.... Rollback just rewrites the channel pointer to that older manifest. The client then sees a manifest with older hashes, compares to its local files, downloads the old objects (still there), and swaps them in.

This means rollback is gated on object survival. If you ran gc after promoting v2 and v1’s unique objects had no other channel pointing at them, they’re gone. moveChannel calls verifyObjects before writing the new channel manifest and refuses the rollback rather than producing a broken pointer.

Garbage collection

patchline gc removes content-addressed objects in objects/sha256/... that aren’t referenced by any release or channel manifest. It does not touch manifests themselves.

That means:

  • Release manifests are your real retention knob. As long as releases/0.9.0/manifest.json exists, all of v0.9.0’s objects are pinned.
  • To genuinely free a version’s storage, you’d have to delete its release manifest first, then run gc. (There’s currently no CLI command for this — the storage backend has DeleteReleaseManifest but it isn’t exposed.)
  • A common pattern: keep an lts channel pinned at the oldest version you want guaranteed-rollback-able to. That keeps its objects alive even after you gc.

Anti-downgrade vs. rollback

The client’s --last-sequence flag rejects manifests with release_sequence ≤ last_sequence. This protects against replay attacks (a stale manifest being served), not against operator rollback: a rollback bumps the channel’s sequence forward, so the new pointer is “newer” by sequence even though its content is older.

If you need to enforce “no client should ever go backwards in version,” that’s a different check — compare the manifest’s version field, not its sequence.

Files removed in newer versions

The client only acts on files present in the current manifest. If v2 added dlc/expansion.dat and you rollback to v1, the v1 manifest doesn’t mention that file, so it stays on the user’s disk as an orphan. Same way a fresh install of v1 into a dirty directory wouldn’t clean it. Not strictly a bug, but worth knowing if you care about leaving exactly v1’s tree on disk.