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 gh call.

The REST API answers inputs: null for 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/repo the 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/repo slug 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.

priority IS the retrigger: it names what failed. offset is 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, including whitelist, 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 name encodes, 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 auto default is the common case – call-orchestrator.yml reads auto as global scope off the whitelist alone, so a --failed retrigger, which puts its selection on priority and 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: whitelist and priority say 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_dispatch input 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 priority input, 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/repo the 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, or docker (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 hard failure (❌) 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 (in repo) that actually contains compose/ swarm deploy jobs. Returns the run dict (with a cached _jobs key) or None.

Walks up to limit recent 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_title at 512 UTF-8 bytes (measured on two runs, both exactly 512) and closes it with a literal .... The run-name template renders priority second-to-last and whitelist last, and on the --failed path 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] Inputs block 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 BlobNotFound as 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 jobs returns them.

  • repo – owner/repo the run lives in.

Returns:

input name -> value, empty when no job of the run records an Inputs block – 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.

state is 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 total column: 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_statuses only 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 priority input, as inputs_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 offset input 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 offset input, a token or a row count.

  • proven – the rows the source run got green (proven_rows()).

Returns:

the index to start reading at; 0 when 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+tor token (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.yml falls back to github.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. regular is 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 offset input.

Returns:

the role#variant token 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#variant the sweep rotation cannot move.

Parameters:

entry – a row of cli.meta.ci.matrix.entries_of().

Module contents