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
directvsindirectis 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:
- one targeted Renovate pass over exactly that tier's repos,
- land the bump MRs it opened, so the release below contains them,
- merge the tier's Release MRs,
- wait for the tags to resolve on
proxy.golang.orgbefore 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. Scheduleplaninstead. - 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-filecannot 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
directrequires 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:
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 theRENOVATE_TARGET_FILTERmechanism a futurerunmode 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.