Skip to content

Liber Quartus · Tome II

Release trains

Every package grim installs carries one version number, and the first two parts of it are the same for all of them.

≈10 min grim 1.0.101
Source

A project is grim-core plus a few dozen modules and themes, each in its own repository. If each of them kept its own version line, every project would face the same question on every update: does this cart work with that core? Trains take the question away. Packages released together share a number, and a shared number means they were built and tested together.

One number, three parts

Core, modules and themes all use MAJOR.MINOR.PATCH. The first two parts are the train: 2.5 is a set of packages known to work together. The last part belongs to each package alone.

Part Means
MAJOR A generation of the platform, such as a PHP or Laravel upgrade. The only part a person decides.
MINOR The train. It moves only when a [release core vX.Y.0] pin asks for it, and then every package is released at X.Y.0, including the ones nobody touched.
PATCH One package, on its own. A fix or a compatible feature in cart releases cart 2.5.3 and nothing else. Core too: a patch of the train moves core and the packages that changed.

What moves a train

A train moves only when someone asks for it. [release core vX.Y.0] in the commit title, with a MAJOR.MINOR above the one the packages ride, is the whole mechanism. It is rare, and it is announced before it is typed, because every project on the old train has to follow.

[release core v2.6.0]   on train 2.5

grim-core   2.5.3 -> 2.6.0
grim-cart   2.5.2 -> 2.6.0
grim-orders 2.5.0 -> 2.6.0   (untouched, released anyway)

Nothing else moves a train. A change to a core module, to core.json or to the skeleton is released as a patch of the train — core takes the next patch, the packages that changed take theirs, and nobody else is re-tagged. Everything else, fix: and plain feat: alike, is a patch of the package it touched. That is a deliberate departure from strict SemVer. If every feature moved the train, there would be a new train every day and grim update would never be quiet.

Two changes that will not go out unpinned

Two kinds of change cannot honestly ride a patch, so the release refuses to plan them until a pin says which train they belong to. The refusal happens before anything is written: no commit, no tags, nothing to undo. Add the pin to the next commit title and the release goes through.

  1. A breaking change, marked feat!: or with BREAKING CHANGE: in the body. Projects follow ~X.Y, so a break needs a train of its own.
  2. A change to config/modules-core.php or config/grim-skeleton.php, which decide what grim-core contains — a module moving in or out of core, or a path starting or stopping to be mirrored. Every project has to react to that.

A change to a skeleton file is not one of these. Only the two configuration files that define the skeleton and the core list are.

This is why the dependencies of a module are never written by hand. The requires in its module.json is generated from what its code imports, and every entry reads ~X.Y, the train it was released on.

A patch of the train

Core needs a fix and nothing else does. That is a patch of the train, and it is what an unpinned core change is released as: core takes the next patch, every module or theme the commits touched takes the next patch of its own, and everything else keeps the tag it has. [release core vX.Y.Z] in the commit title, with the X.Y the packages already ride, names the patch explicitly instead of letting the release count it.

[release core v2.5.3]   on train 2.5, cart changed

grim-core   2.5.2 -> 2.5.3
grim-cart   2.5.1 -> 2.5.2
grim-orders 2.5.0    (untouched, not re-tagged)

The next version of a package is counted from its published tags, not from the number in its manifest. A package patched on its own since the train was cut carries a tag its manifest may not mention, and a release planned from the manifest would land on that tag. The release reads the tags of every package it is about to publish and refuses, before it writes anything, to plan a version that does not exceed them. The same guard stops a pin below the train the packages are on, and a new train whose X.Y.0 some package already carries. A pin that lands on nothing, into repositories with no tags above it, starts a new version line, which is how a new major begins.

The train itself is counted the same way. When core.json says less than the newest core tag on its train — a release commit that never reached master, a patch tagged past the manifest — the release plans from the tag and says so (core.json says 1.3.0, published 1.3.4 — planning from the tag); the release commit that follows repairs the manifest. A core tag on a higher train than the manifest is not taken as a base, because a train is announced, never stumbled into: the plan stays on the manifest's train and refuses, and the way out is an explicit pin above the tag. A train that was released but never got its train.json is completed after the fact with grim monorepo:manifest X.Y.Z --publish.

A package founded after the train was cut carries no tag on it. Its first release joins the train at X.Y.0 rather than patching above its manifest, so that the ~X.Y other packages declare can resolve to it.

A pull request's dry run has no access to the package repositories and plans from the manifests. The sync itself reads the tags, so the pull-request comment can show a version the sync later refuses. The refusal is the safe outcome: nothing is committed or tagged.

Only forward, one train at a time

There is one supported train, the newest. A fix is released as a patch on it, and a project on an older train gets that fix by moving to the newest one. There are no release branches, no backports and no long-term trains.

That is also why grim update refuses to move a single module once a newer train exists. A cart from 2.5 on a core from 2.4 is exactly the pair nobody tested. Inside the train a project is on, one module can take a patch alone, and the recipe Take one module fix mid-train is that move.

What a constraint in grim.json allows

grim create-project writes the train of the day into a new grim.json, for core, every module and every theme. The constraint on core decides which trains grim update may move to. Modules and themes then follow core onto that train, whatever their own constraints say.

{
    "core": "~2.4",
    "requires": { "cart": "~2.4", "payments": "~2.4" },
    "themes": { "admin": "float:~2.4" }
}
core Follows
"~2.4" Train 2.4 and every later train of major 2. Composer reads a two-part tilde as >=2.4 <3.0.0, so this is not a pin.
"2.4.*" Exactly one train. Patches still arrive. When 2.5 ships, grim update reports that the project is up to date.
"^2" Every train of major 2.

grim require follows the same rule from the other side. It writes the constraint of the project's train for the new module and resolves it, and everything it depends on, inside that train.

The lock is where the project is

grim.json says what the project may follow. grim.lock records where it is: the train, the exact core version and commit, and the version and commit of every module and theme. Read the lock, not grim.json, when you need to know what runs.

The two commands that read it do opposite things. grim install reproduces the lock, so a fresh clone lands the versions your colleagues committed even when newer ones exist. grim update moves the project and writes the lock again, whole. The recipe Move a project to a new train is the second one done with care.

Tags, and the one thing not on the train

Package tags are bare numbers: 2.5.0, never v2.5.0. The CLI is the exception. grim itself is not part of any train, has its own version line, and tags its releases with a v.

grimoiry/grim-core               2.5.0
grimoiry/grim-cart               2.5.3
grimoiry/grim-theme-admin-float  2.5.0
grimoiry/grim-cli                v1.4.0

A new grim does not move a project's train, and a new train does not need a new grim. They meet in one place, the infrastructure files, which come from the templates of the CLI and are renewed with grim upgrade.