The mental model behind RelEasy. For the schema, see configuration.md; for commands, see commands.md.
origin/antalya-26.3: * (stable base branch on origin — you maintain it)
|
feature/antalya-26.3/pr-42: * --- fix (PR → antalya-26.3)
feature/antalya-26.3/pr-99: * --- feat (PR → antalya-26.3)
You maintain the base branch. Each PR you want to port becomes its own port branch carrying the cherry-picked commits, opened as a rebase PR back into the base. RelEasy never creates or rewrites the base.
releasy run does:
- Discover PRs from
pr_sourcesin the session file. - For each PR / group, create
feature/<base>/<id>from the base. - Cherry-pick the PR merge commit(s).
- Push and open a PR into the base (when
push: trueandpr_policy.auto_pr: true).
On conflict: pipeline stops with instructions. Resolve, then
releasy continue, then
releasy run again.
The other commands fit around this loop — see the at-a-glance matrix.
Base branch = target_branch from config, or derived
<project>-<version> (with <version> parsed from --onto).
--onto is a naming label — never resolved as a git ref.
| Type | Pattern | Example |
|---|---|---|
| Base | target_branch or <project>-<version> |
antalya-26.3 |
| Feature | feature/<base>/<id> |
feature/antalya-26.3/s3-disk |
| Origin PR | feature/<base>/pr-<N> |
feature/antalya-26.3/pr-42 |
| External PR | feature/<base>/<owner>-<repo>-pr-<N> |
feature/antalya-26.3/ClickHouse-ClickHouse-pr-12345 |
Each config.yaml has a required name:. That name keys a state file +
lock under ${XDG_STATE_HOME:-~/.local/state}/releasy/ (override with
$RELEASY_STATE_DIR). Different-named projects run truly concurrently;
same-named projects serialize on the lock.
(cd ~/work/antalya-26.3 && releasy run) &
(cd ~/work/antalya-25.8 && releasy run) &
releasy list # see every project on this machineOne
work_dirper project — git itself isn't safe with two processes mutating the same checkout.
Moving a config.yaml trips an ownership check. Fix with
releasy adopt from the new location.
| Path | Purpose | Edited by |
|---|---|---|
config.yaml |
Stable per-project config — origin, target branch, AI, notifications. Schema. | You; scaffolded by releasy new. |
<config-dir>/<target_branch>.session.yaml |
features: + pr_sources: (falls back to <name> when target_branch is unset). Schema. Override path with session_file: or --session-file. |
You; mutated by releasy feature *. |
${XDG_STATE_HOME:-~/.local/state}/releasy/<name>.state.yaml |
Pipeline state (phase, branches, statuses, AI cost). | Auto. Not user-editable. |
${XDG_STATE_HOME:-~/.local/state}/releasy/<name>.lock |
POSIX advisory lock. | Auto. Crash leftovers self-reclaim. |
The state file remembers the owning config.yaml's absolute path, so
releasy list can show the back-link and a
moved/copied config trips a clear ownership error.
When AI resolve is disabled or gives up, RelEasy flags the unit for manual review and keeps the pipeline moving. Two flavours:
Singleton (or first PR of a group) — no useful commits yet, so:
abort cherry-pick, delete local port branch, mark Conflict on the board,
do not push, do not open a PR.
Partial group (later PR in a group fails) — earlier picks are valid, so:
abort failed pick, push the branch, open a draft PR labelled
ai-needs-attention with a banner explaining the failure, mark Conflict.
After conflicts:
| You want to… | Run |
|---|---|
| Re-attempt a source PR you just fixed manually | releasy run |
| Mark a manually-resolved port branch as done | releasy continue |
| Resolve target-drift conflicts on an open rebase PR | releasy refresh |
| Drop a port from this run | releasy skip |
| Resync the GitHub Project board | releasy project push |
Status semantics: clean or AI-resolved port with rebase PR open →
needs_review. Pushed branch without PR → branch_created. Needs human →
conflict. Rebase PR landed → merged. Rebase PR closed without merging
→ closed (terminal — pr_policy.recreate_closed_prs lets a fresh
<canonical>-1 / -2 / … attempt opt back in). Another PR on the same
base already cherry-picks the source(s) → superseded (terminal — gated
by pr_policy.detect_superseded). AI involvement is signalled by
ai_resolved + the ai-resolved PR label.
For per-PR hints to the resolver, see
ai_context.
status says what a parked unit is; a stall says why it stopped and
whether re-running could change that. It is recorded on the state entry
(stall:) on every non-clean exit, and cleared the moment the unit lands,
merges, is skipped, or has its conflict resolved.
A stall is a generic kind plus the specifics — a short detail, the units
and PRs it waits on, when it was first seen, and how many consecutive runs
have ended in it:
stall:
kind: waiting_for_merge
waiting_on_units: [auto-grp-pr-1687]
waiting_on_prs: [https://github.com/Altinity/ClickHouse/pull/1687]
since: "2026-08-05T09:14:02+00:00"
runs: 3| Kind | Meaning | Blocks a retry? |
|---|---|---|
waiting_for_merge |
A prerequisite is queued in another unit — listed there, or carried inside a combined port that unit brings — or a depends_on gate is unmet. Nothing to try until that PR merges. |
yes |
missing_prereq |
A prerequisite PR was identified but nobody ports it: add it to the session, or merge it upstream. | yes |
retries_exhausted |
An attempt cap was spent (max_partial_continue_attempts, max_verify_resume_attempts). |
the cap does |
unresolvable |
The resolver judged the conflict and could not fix it. | no |
prereq_search_exhausted |
The auto-prereq dive hit its depth cap, a cycle, or a fetch failure. | no |
resolver_unavailable |
AI resolution is off, or the backend died before reaching a verdict. | no |
build_unfixed |
The resolution landed but the build/tests never went green. | no |
Blocking stalls skip the unit on the next run
instead of paying for a resolution that can only reach the same verdict. The
skip is not permanent — it lasts exactly as long as the thing being waited on:
a waiting_for_merge clears when one of its units reaches merged /
superseded (or leaves the session), a missing_prereq clears once the
prereq is queued somewhere releasy knows about. retries_exhausted isn't
gated here because the caps are re-read from config every run, so raising one
takes effect immediately.
Set pr_policy.honor_stall_reasons: false to always
re-attempt, or pass run --ignore-stalls for a single run.
Stalls surface in releasy status (a Why column), on the graph issue
(graph sync), on the project board card,
and in the draft PR's "needs manual intervention" banner.
Rebase PR title: "<Project> <version>: <subject>" — e.g.
"Antalya 26.3: Token Authentication and Authorization". Version comes
from the base branch; project is title-cased only if all-lowercase
(ClickHouse preserved). A leading <version>[<project>]: prefix on the
source title is stripped first.
Labels: every PR gets releasy (auto-created on first run with
push: true). AI-resolved PRs also get ai_resolve.label (default
ai-resolved). Optional merged_label is stamped on the rebase PR when it
merges and stripped from the source PRs — see
merged_label.
Cross-repo PR URLs are read-only sources for cherry-picks. RelEasy only
ever creates, updates, labels PRs in the origin repo and only ever
pushes branches to that same remote. Enforced at the API helper layer
(create_pull_request, update_pull_request, add_label_to_pr,
ensure_label, force_push) — there's no parameter to point elsewhere.
releasy run prints PRs will be opened against <owner>/<repo> (origin)
on startup so the target is visible. If you see writes to anywhere else,
it's a bug — please report.