cli.administration.deploy.ci package¶
Subpackages¶
Submodules¶
cli.administration.deploy.ci.gh module¶
Git and gh plumbing for the CI deploy-run status/trigger commands.
- cli.administration.deploy.ci.gh.current_branch() str¶
- cli.administration.deploy.ci.gh.fetch_jobs(run_id: str, repo: str | None = None) list[dict]¶
- cli.administration.deploy.ci.gh.fetch_run(run_id: str, repo: str | None = None) dict¶
Jobs plus the run title, in one
ghcall.The REST API answers
inputs: nullfor a finished workflow_dispatch, so the inputs are recovered from the run itself: short ones from the title (config_from_title), the long ones from a called job’s log (inputs_from_jobs), which the title truncates.
- cli.administration.deploy.ci.gh.resolve_repo() str¶
The
owner/repothe current branch lives on, derived from its tracking remote (not gh’s default repo, which may be the upstream).
- cli.administration.deploy.ci.gh.run_id_from_url(url: str) str¶
- cli.administration.deploy.ci.gh.slug_from_url(url: str) str¶
Extract the
owner/reposlug from a github.com URL (https or ssh).
cli.administration.deploy.ci.runs module¶
Shared helpers for the CI deploy-run status/trigger commands.
Reads GitHub Actions runs via the gh CLI and maps the per-app
compose (“docker”) and swarm deploy jobs to pass/fail/abort states.
A mode job that ran but did not finish successfully (cancelled, timed out,
still running) counts as a failure; a mode the role has no job for is N/A
and never fails the aggregated total column.
Deploy jobs are matched by the compose/swarm/host glyph (from the symbol
glossary, the single source of truth) their test-deploy-{compose,swarm,host}.yml
matrix titles them with – NOT the words “Compose”/”Swarm”/”Host”, which no
real job carries (matching those made --failed swarm silently find
nothing). What follows the glyph is the role’s display name
(utils.roles.display), decoded back to the role id; a bare role id from a
run that predates the display names decodes just as well. The orchestrator
prepends a “caller / “ prefix and the shard suffix (e.g. “ 0,1”) rides along
in the display name; both are tolerated.
- cli.administration.deploy.ci.runs.LOG_INPUTS = ('tor',)¶
Inputs the retrigger reads from the job log rather than the run title.
The title renders these as glyphs, so recovering one means translating a symbol back into a value; the log holds the word the operator picked. Reading it there keeps the retrigger honest even if the title’s marker changes shape.
- cli.administration.deploy.ci.runs.SELECTION_INPUTS = ('priority', 'offset')¶
The two inputs a retrigger decides itself rather than carrying over.
priorityIS the retrigger: it names what failed.offsetis recomputed rather than carried, but from the carried value up: the source run got a stretch behind its own offset green before its budget ran out, so the regular line resumes behind that stretch (cli.administration.deploy.ci.selections.resume_offset()) instead of repeating it, and never falls back behind where that run started. Everything else is carried verbatim, includingwhitelist, so a retrigger of a scoped run stays inside that scope instead of quietly widening to the whole repository.
- cli.administration.deploy.ci.runs.app_of_job(name: str) str | None¶
The role id a deploy job
nameencodes, or None if it is not one.
- cli.administration.deploy.ci.runs.carried_inputs() tuple[str, ...]¶
The inputs a retrigger reproduces: every dispatch input except the selection it computes itself.
- cli.administration.deploy.ci.runs.cell(state: str) str¶
- cli.administration.deploy.ci.runs.config_from_run(title: str, logged: dict[str, str] | None = None, jobs: list[dict] | None = None) dict[str, str]¶
Every input the source run was dispatched with, except the selection.
The job log is the source: it records all inputs verbatim, including the ones the title renders as a glyph and the ones it renders not at all. The title fills the gaps when a log is unreadable.
An input the source left on its default resolves to empty and is dropped, so the retrigger leaves it on that same default rather than pinning today’s default into a run that never asked for it.
A suite is turned off only when every job of it came back green; one that failed, was cancelled or never ran at all keeps it on the retrigger, so a retrigger spends its runners on what is not yet proven.
What the source asked for does not enter that decision: the run’s own jobs are the evidence. A suite reached on the
autodefault is the common case –call-orchestrator.ymlreadsautoas global scope off thewhitelistalone, so a--failedretrigger, which puts its selection onpriorityand leaves the whitelist empty, would run every green suite again for as long as the retrigger chain lasts.- Parameters:
title – the source run’s display title.
logged – inputs read verbatim from a called job’s log (
inputs_from_jobs()).jobs – the source run’s jobs, which record the suites it already passed (
suite_passed()).
- cli.administration.deploy.ci.runs.config_from_title(title: str) dict[str, str]¶
Configuration inputs a manual run was dispatched with, keyed by input name. An input left on its workflow default renders no title segment and is absent here, so the retrigger leaves it on that same default.
Only the configuration is recovered, never the selection:
whitelistandprioritysay which roles ran, and a retrigger computes those itself. Runs from any other entry point carry an unrelated title and yield nothing at all.
- cli.administration.deploy.ci.runs.dispatch_inputs() tuple[str, ...]¶
Every
workflow_dispatchinput the manual entry declares.Read from the workflow rather than listed here, so an input added to the form is carried over without anyone remembering to teach this module about it – the failure mode otherwise is silent: the retrigger runs with that input on its default and nothing says so.
- cli.administration.deploy.ci.runs.dispatch_workflow(workflow: str, ref: str, whitelist: str = '', *, priority: str = '', config: dict[str, str] | None = None, repo: str | None = None) None¶
Dispatch workflow, carrying config (see
config_from_title()) over verbatim. An input left out keeps the workflow’s own default.The role selections go out as display names (
utils.roles.display), so the run title reads them back the way the deploy jobs are titled. The workflow decodes them again before any consumer sees a role id.
- cli.administration.deploy.ci.runs.dispatched_priority(source: dict, repo: str) str¶
The source run’s
priorityinput, whole.The title’s priority segment is the cross-check, not the source: it proves a priority line existed even when truncation mangled it, so an empty log read is a broken reader – expired logs, a moved Inputs block, an inlined orchestrator – rather than a run dispatched without one.
- Parameters:
source – the run as
fetch_run()returns it.repo –
owner/repothe run lives in.
- cli.administration.deploy.ci.runs.failed_roles(statuses: dict[str, dict[str, str]], scope: str = 'total', *, strict: bool = False) list[str]¶
Roles that are not green for the given scope:
total(a mode that ran is not green),swarm, ordocker(compose). A role with no job in the requested mode is skipped, not failed: a host driver that never deploys in swarm is not a swarm failure, only roles whose swarm job ran and did not pass are.With
strict, only a hardfailure(❌) selects a role; cancelled, timed-out, skipped (🚫) and still-running (⏳) modes do not.
- cli.administration.deploy.ci.runs.find_last_deploy_run(branch: str, repo: str | None = None, limit: int = 15) dict | None¶
Newest run on
branch(inrepo) that actually contains compose/ swarm deploy jobs. Returns the run dict (with a cached_jobskey) or None.Walks up to
limitrecent runs because the very latest run on a branch is often a lint-only or skipped event with no deploy matrix.
- cli.administration.deploy.ci.runs.inputs_from_jobs(jobs: list[dict], repo: str) dict[str, str]¶
The dispatch inputs a called job’s log records verbatim.
GitHub cuts
display_titleat 512 UTF-8 bytes (measured on two runs, both exactly 512) and closes it with a literal.... The run-name template rendersprioritysecond-to-last andwhitelistlast, and on the--failedpath the whitelist is empty, so the cut always lands inside the priority list: measured 21 of 69 roles survived, the 21st a half-token. A CJK display name costs 3 bytes per character, so the budget is ~22 display names against ~28 bare role ids. Every job of the called workflow opens its log with an##[group] Inputsblock holding the full values, so that is the SPOT for anything the title cannot hold.Candidates are read shallowest first, because a depth-1 job records the orchestrator’s own inputs while a deeper one records what its caller forwarded. Skipped jobs are passed over: they never upload a log blob, so their log endpoint answers
BlobNotFoundas a bare HTTP 404 – and a force-cancelled run can leave every depth-1 job skipped, which is why the deeper jobs are read at all rather than treated as inputs-free.- Parameters:
jobs – the run’s jobs, as
gh run view --json jobsreturns them.repo –
owner/repothe run lives in.
- Returns:
input name -> value, empty when no job of the run records an
Inputsblock – including when the logs have expired.
- cli.administration.deploy.ci.runs.parse_role_statuses(jobs: list[dict]) dict[str, dict[str, str]]¶
Map
app id -> {"docker": state, "swarm": state}from gh job dicts.stateis the raw effective outcome (‘success’ / ‘failure’ / ‘running’ / ‘cancelled’ / …). Modes without a job are simply absent. A role’s variant shards run as separate jobs per mode; the mode state aggregates them worst-first, so one green shard can never mask a failed sibling (gitlab swarm variant 1 red, variant 0 green -> swarm failure).
- cli.administration.deploy.ci.runs.parse_role_urls(jobs: list[dict]) dict[str, dict[str, str]]¶
Map
app id -> {"docker": url, "swarm": url}of the job html URLs, keeping the URL of the worst shard so links point at the failing job.
- cli.administration.deploy.ci.runs.suite_passed(jobs: list[dict], suite: str) bool¶
Whether every job of
suite(‘workspace’) completed successfully.Only a clean sweep of successes retires the suite. A shard that failed, was cancelled, timed out or skipped did not run through, and a run that holds no job for the suite at all never started it – none of those is evidence the suite is good, so the retrigger keeps asking for it.
- cli.administration.deploy.ci.runs.total_state(modes: dict[str, str]) str¶
Aggregate the per-mode states into the
totalcolumn: green only when every mode that actually ran is green. A mode the role has no job for is N/A (a host driver never deploys in swarm) and does NOT fail the total;parse_role_statusesonly lists roles with at least one deploy job, so there is always a present mode to judge.
- cli.administration.deploy.ci.runs.untriggered_priority(priority: str, statuses: dict[str, dict[str, str]]) list[str]¶
Roles the source run’s priority line named but never deployed at all.
A priority role can fall behind the discovery budget cut in every mode, so the run holds no job for it and
failed_roles()cannot see it — it is neither green nor red, it simply never ran. Carrying it into the retrigger is the only way it ever gets deployed.A priority entry may pin axes (
web-app-zammad#1@compose+tor), and the pin rides along into the retrigger: the entry never ran, so the run holds no evidence that the axes it named were the wrong ones. Only the name in front of the axis suffix is resolved against the statuses.- Parameters:
priority – the raw
priorityinput, asinputs_from_jobs()reads it back. A truncated value would smuggle a half-token into the retrigger’s role set, so an undecodable name raises instead.statuses – role -> mode -> state, from
parse_role_statuses().
- Returns:
the entries whose role has no job at all in the source run, each with its own axis suffix intact.
cli.administration.deploy.ci.selections module¶
Turn a run’s deploy jobs back into selection tokens.
A deploy job title carries the whole row it deployed – role, variant, mode,
onion state, distro, filesystem – and utils.github.variant.selection
is the grammar that writes it back down.
The filesystem is the one axis a token built here leaves out. The title states
the kind the matrix assigned, which a deploy is allowed to fall back from
when the host cannot serve it, so the title does not prove what the job ran on.
Writing it into a token would also state it as named by a human, which makes it
binding (utils.github.variant.axes.assign()): a retrigger would then fail
on the very condition the fallback exists to absorb. The retrigger lets the
rotation assign it again and reports what it got. Reading the tokens out of a finished run is what lets a
retrigger replay exactly what failed and start the regular line where that run
stopped, instead of aggregating to role ids and guessing the rest.
- cli.administration.deploy.ci.selections.carried_index(regular: list[dict[str, str]], carried: str, proven: set[str]) int¶
Where in regular the source run began reading.
A retrigger inherits the window its source was given: the rows in front of that window were proven by the runs before it, and re-walking them from the head would redeploy a green stretch that nobody doubts. The source run’s own
offsetinput is that window’s start.The token can name a row this ranking no longer has – the role was removed, a variant was dropped, the filters moved. The start is then the first row the source run got green, which lies inside the same window and so never falls behind it. A run with nothing green and an unresolvable token leaves no anchor at all and starts at the head.
- Parameters:
regular – the regular line of the retrigger’s own discovery, in ranking order.
carried – the source run’s
offsetinput, a token or a row count.proven – the rows the source run got green (
proven_rows()).
- Returns:
the index to start reading at;
0when the source run was given no offset.
- cli.administration.deploy.ci.selections.collapse_to_roles(tokens: Iterable[str]) list[str]¶
The same selections with every axis dropped, one entry per role.
failed_selections()reproduces the combination that broke, which is what a handful of red rows deserves. Once most of the ranking is red the question is no longer which combination broke but whether the role comes up at all, and a token per combination spends the priority budget proving the same role red twice. The role name lets the rotation pick the axes.What this gives up is the guarantee the pinned form exists for: the combination that failed may not be among the ones the retrigger draws.
- Parameters:
tokens – selection tokens, as
failed_selections()returns them.- Returns:
sorted, deduplicated role ids.
- cli.administration.deploy.ci.selections.failed_selections(jobs: list[dict], *, strict: bool = False) list[str]¶
The selection tokens that reproduce exactly what did not pass.
A role aggregated to its id loses what actually broke: the retrigger then redeploys every variant of it, in whatever mode and onion state the sweep rotation happens to pick, and the combination that failed may not be among them. Each failed job therefore contributes its own
role#variant@mode+tortoken (utils.github.variant.selection), so the priority line replays that job and nothing else.Every mode is read; there is no scope to narrow to. A run that failed in swarm and in compose comes back as two tokens for the same role.
- Parameters:
jobs – the source run’s jobs.
strict – only hard failures (❌) count; cancelled, timed out and still running are left out.
- Returns:
sorted, deduplicated tokens.
- cli.administration.deploy.ci.selections.proven_rows(jobs: list[dict]) set[str]¶
The rows the source run proved, as
role#variant, axes dropped.Mode, onion state, distro and filesystem are assigned by the rotation over the sweep number, and a retrigger is a new run with a new number (
call-orchestrator.ymlfalls back togithub.run_number). Comparing the full deploy tokens therefore compares two different sweeps: measured against the current ranking, 2 of 277 rows matched, so every walk stopped on its first row and the regular line never left the head. The row is the coarsest identity the rotation cannot move.Dropping the axes does not mean forgiving them. A row is proven only when every job it had was green: one red or aborted combination un-proves it, however many green siblings it has. Only a priority row can have several, since it deploys its whole cross-product at once while a regular row gets one combination per sweep – and letting a green compose job carry a red swarm one would walk the regular line past a combination nobody fixed.
- Parameters:
jobs – the source run’s jobs.
- cli.administration.deploy.ci.selections.resume_offset(regular: list[dict[str, str]], proven: set[str], leading: Iterable[selection.Pin] = (), carried: str = '') str¶
Where a retrigger should pick the regular line up again.
The source run read a window of the ranking, starting where carried put it (
carried_index()) and stopping at its budget. Only the green part of that window is newly settled, so the answer is the last row of the leading run of successful rows behind the window’s start, as a selection token – a token still names the same row after the ranking shifts, a row count does not.The window’s start is the floor: with nothing green behind it the answer is the start itself, never the head. Walking back to the head is what made every retrigger redeploy the whole stretch its predecessors had already proven.
Stops at the first row that is not proven, whether or not the priority line claims it, and one red combination is enough to un-prove a row (
proven_rows()). Resuming past it would drop every other combination of that row and everything the run never reached behind it.A row the priority line claims is never the answer.
regularis discovered without the priority line (see the retrigger’s_ranking), so it still holds rows the run will hand to that line instead (cli.meta.ci.matrix.candidates()): a variant-less pin takes its whole role, a pinned one takes those variants. Naming such a row would emit an offset that resolves against nothing once the run applies the priority line, and abort the discovery of every chunk.- Parameters:
regular – the regular line of the retrigger’s own discovery, in ranking order.
proven – the rows the source run got green in every combination it ran them in (
proven_rows()).leading – the retrigger’s priority pins, whose rows leave the regular line.
carried – the source run’s own
offsetinput.
- Returns:
the
role#varianttoken to resume at, or''when the source run started at the head and got nothing skippable of this line green – then the retrigger starts at the head too, as it would without an offset.The returned token names a place in the ranking, so it carries no axes, and neither does the membership test behind it (
proven_rows()): the source run recorded its combinations under its own sweep number and the retrigger gets a fresh one, so comparing them row by row is the only comparison that survives the rotation.
- cli.administration.deploy.ci.selections.row_identity(entry: Mapping[str, Any]) str¶
One matrix row as the
role#variantthe sweep rotation cannot move.- Parameters:
entry – a row of
cli.meta.ci.matrix.entries_of().