Skip to content

release-train

Derives the estate's first-party dependency order from its go.mod files and plans the release cascade in it.

include:
  - component: gitlab.com/phpboyscout/cicd/[email protected]
    inputs:
      group: phpboyscout
      module_prefix: gitlab.com/phpboyscout

What problem this solves

Renovate is per-repository and stateless across repositories. It answers "what newer versions exist for this repo's dependencies right now?" — and the state a cascade needs (which releases are pending, what the topological order is, whether a tag has landed) has nowhere to live in that model.

Two consequences, both observed on this estate:

  • It cannot wait. Hop N+1 only becomes visible once hop N has been released, so an eight-tier cascade needs eight correctly-ordered human interventions.
  • It emits incompatible sets. A core module moves while its adapters stay behind, because direct vs indirect is the boundary Renovate uses and core vs adapter is the one that matters.

This component supplies the missing half: what depends on what, and therefore what must be released before what. It does not replace Renovate — detection is the part that already works.

The two modes

plan is side-effect free. It reads, it orders, it reports. Safe to schedule.

run walks the tiers upstream-first. Per tier:

  1. one targeted Renovate pass over exactly that tier's repos,
  2. land the bump MRs it opened, so the release below contains them,
  3. merge the tier's Release MRs,
  4. wait for the tags to resolve on proxy.golang.org before advancing.

run is a dry run unless execute: "true" — it reports every action it would take and changes nothing. Rehearse first; the dry run against a real estate is the cheap way to find out what a train is about to do.

Three refusals

  • A scheduled pipeline cannot execute, even if someone sets execute: "true". Cutting releases is the maintainer's call per train, never standing; a nightly executing train is exactly the standing authorisation that forbids. Schedule plan instead.
  • A tier that fails halts the train. No skipping. A cascade stopped at tier 4 is recoverable; one that skipped tier 3 and released 4-7 against a stale tier 3 has published dud versions, which is the failure this exists to prevent. Completed tiers are already released, so fix the cause and re-run.
  • --from-file cannot drive a run. Fixture data is for testing the ordering, not for acting on it.

What checking means

detailed_merge_status: checking is not a blocker — it is GitLab still computing mergeability, and it clears in seconds. The first whole-estate rehearsal reported 42 of them as blockers, which would have sent someone investigating twenty perfectly healthy repos. The walk now waits for the status to settle before deciding.

Two related distinctions the same rehearsal forced:

  • A pipeline that is still running is not broken. In a rehearsal it reports WOULD WAIT; only an executing run, which actually needs the answer now, treats it as a blocker.
  • A draft merge request is skipped, never blocked on. Renovate marks major bumps draft by policy, and reporting one as a blocker turns a decision somebody already made into a problem to fix.

Between them these took a whole-estate rehearsal from 29 blockers to 1.

Why the tag check matters

A merged Release MR is not a release. The tag pipeline still has to run, and the proxy still has to serve it, before the next tier's Renovate pass can possibly see the new version. Advancing early bumps the next tier to a version that does not exist yet — and fails in a way that looks like a Renovate bug.

The check asks for the explicit version cut, never @latest, which lags and reports the previous version for a release that propagated fine.

Jobs

Job When
release-train On a pipeline schedule setting $RELEASE_TRAIN_TASK=plan (override with if). An executing run is triggered deliberately, never scheduled.

Inputs

Input Type Default Description
group string (required) Group whose projects form the graph, e.g. phpboyscout. Subgroups included. No default — an orchestrator must state the estate it plans for.
module_prefix string (required) Prefix marking a dependency as first-party, e.g. gitlab.com/phpboyscout. Only requires under it become edges. No default: guessing it from group would silently yield an empty graph for any estate whose module paths differ from its group path — and an empty graph plans successfully.
roots string "" Comma-separated repos to narrow the walk to; the plan then covers those plus everything transitively depending on them. Empty plans the whole estate. A string, not an array — component array inputs cannot be iterated in shell.
exclude string "" Comma-separated repos to drop from the walk entirely. Reserved repos are a fact of a live estate; filtering them explicitly beats remembering to mid-run. Exclusions are named in the report, so a narrowed run never reads like a full one.
settle_seconds string "60" How long to let GitLab finish computing mergeability before treating a status as final. See what checking means.
mode string plan plan or run — see the two modes.
execute string "false" Only meaningful with mode: run. false rehearses; true acts. A separate input rather than a third mode, so turning a rehearsed plan into a real one is one visible word.
renovate_project string phpboyscout/cicd Project carrying the renovate-group job the walk triggers per tier.
renovate_ref string main Ref to trigger that pipeline on.
poll_seconds string "30" How often the walk re-checks a pipeline or tag it is waiting on.
renovate_timeout string "2400" Seconds to wait for a tier's Renovate pass. A 4-repo targeted run measures ~377s against ~68 min for the full group, so this is headroom.
tag_timeout string "1800" Seconds to wait for a tier's tags to reach the proxy.
token string $RELEASE_TRAIN_TOKEN Read-only for plan; run with execute also merges and triggers. See token scope.
image string …/dev-tools:v0.3.4 Runtime image. Needs only python3 — the engine is standard-library only, so there is no install step and no lockfile to rot.
stage string release Stage to assign the job to. Declare it in your stages:.
plan_artifact string release-train-plan.json Path for the machine-readable plan, kept as an artifact (when: always).
artifacts_expire_in string 1 week How long to keep it.
engine_path string "" Run a consumer-local engine instead of the embedded one, for developing against a real estate without cutting a release.
if string $CI_PIPELINE_SOURCE == "schedule" && $RELEASE_TRAIN_TASK == "plan" Gating expression.
timeout string 30 minutes Planning is minutes even across a large estate; this is headroom.

Token scope

The token default is $RELEASE_TRAIN_TOKEN, not $CI_JOB_TOKEN — a deliberate deviation from the authoring rule that token inputs default to the job token.

A job token is scoped to its own project plus an allowlist. This job reads every project in the group, so a job-token default would appear to work and silently plan against a partial graph — which is the one failure this component must never have, because a partial graph plans successfully and is confidently wrong. The job refuses to start with no token at all for the same reason.

read_api is sufficient for plan. An executing run also merges merge requests and triggers a pipeline, so it needs write scope — a further reason executing runs are deliberate rather than scheduled.

How the order is derived

From the go.mod on each project's default branch, every run. There is no checked-in tier list, deliberately: a stale one would drift and then sequence confidently and wrongly, which is worse than not sequencing at all.

  • Edges come from direct requires only. An indirect first-party dependency is satisfied by whatever pulls it in, so it does not constrain release order. It still needs bumping — that is a per-tier concern, not an ordering one.
  • Tier is longest path, not shortest. A repo must not be released until every dependency beneath it has been, so it sits one past its deepest dependency. Shortest-path would release a repo before a sibling it depends on.
  • A cycle is a hard error. The job fails and names the cycle rather than picking an arbitrary order.

Reading the plan

The job log carries the human-readable order; plan_artifact carries the same thing as JSON.

release order — 76 repos (whole estate), 8 tiers

tier 0  (7 repos, 5 pending releases)
    phpboyscout/go/errors  <- !13 chore(main): release v0.2.1
    ...

84 pending release(s). Release each tier fully before starting the next.

Release each tier fully before starting the next. Getting this backwards is not a style preference: releasing leaves-first means every leaf is re-cut once per upstream tier that moves after it. On this estate a change to go/errors costs 69 releases in 7 ordered rounds, against up to 178 unordered.

Narrow to what actually changed rather than planning the whole estate, and drop anything reserved:

    inputs:
      roots: "phpboyscout/go/errors"
      exclude: "phpboyscout/krites"

Rehearse before you run

A dry run over the real estate is the cheap way to find out what a train is about to do, and it surveys rather than stopping at the first problem — a rehearsal that halts makes you re-run it once per blocker.

An executing run does the opposite and halts, because continuing past a blocker would release a tier against a stale dependency.

The first whole-estate rehearsal on this estate found a repo that had been created the same day as a hand-enumerated sweep, and had therefore missed it — shipping on a Go version with two known advisories. Deriving the graph from the forge every run is what makes that findable at all.

See also

  • renovate-group — the detection half, and the RENOVATE_TARGET_FILTER mechanism a future run mode drives per tier.
  • releaser-pleaser — what opens the Release MRs this plans the order of.
  • spec 0067 — the measurements, the alternatives rejected, and the decisions.