Commit Graph
70 Commits
Author SHA1 Message Date
evangelostsak 99fcca4d1b feat(ci): compose titles carry the direct arm glyph
A compose row never joins a WireGuard mesh, so its title shows 🛣️ like a
direct swarm row. The vpn axis, the planner and the deploy are unchanged.
2026-10-17 00:03:18 +02:00
evangelostsak 5d7327d911 revert(ci): compose titles carry no mesh glyph and take no mesh pin check 2026-10-16 21:26:16 +02:00
evangelostsak dc84a4c9ac revert(ci): the vpn axis covers swarm rows only again 2026-10-16 21:26:16 +02:00
evangelostsak 0b1bc82d57 refactor: drop rationale and narration from utils/github/variant/vpn.py 2026-10-16 19:10:01 +02:00
evangelostsak f5b2979c45 refactor: drop rationale and narration from utils/github/variant/selection.py 2026-10-16 19:10:01 +02:00
evangelostsak 216f6ea170 refactor: drop rationale and narration from utils/github/variant/label.py 2026-10-16 19:10:01 +02:00
evangelostsak aaecbccf8c refactor: drop rationale and narration from utils/github/variant/axes.py 2026-10-16 19:10:01 +02:00
evangelostsak 1860f587b4 feat(ci): show the mesh state on every compose title and refuse a mesh pin it cannot take
A compose row has two behaviours, with or without the mesh, but its title
showed three: no glyph on a role's larger variants, 🛣️ or 🚇 on its smallest.
No glyph and 🛣️ deploy the same thing, so every compose title now reads 🛣️
or 🚇 like a swarm title, and host titles stay without one. Only the smallest
variant still rotates onto the mesh.

A &vpn pin on a row no mode of which can take the mesh now aborts the run
instead of quietly deploying 🛣️, and a regular row pinned to the mesh only
rotates onto a mode that can carry it.
2026-10-16 14:48:36 +02:00
evangelostsak a206abd8d5 docs(ci): every swarm and compose row carries the mesh or direct glyph 2026-10-16 14:48:35 +02:00
evangelostsak 5cd28fd83a refactor(ci): drop the re-export of the removed mesh glyph modes 2026-10-16 03:40:16 +02:00
evangelostsak de804fe304 feat(ci): assign the vpn axis and its glyph to smallest compose variants
A compose row of any other variant keeps its title unchanged.
2026-10-16 03:40:16 +02:00
evangelostsak 1052be4754 feat(ci): compose rows of a role's smallest variant take the vpn axis
The same input and rotation as swarm. Only the smallest variant is meshable
in compose, so the CLI test of svc-net-wireguard runs once per role rather
than once per variant.
2026-10-16 03:40:16 +02:00
evangelostsak 1c5ba164f2 Merge remote-tracking branch 'upstream/main' into feature/svc-net-wireguard
No conflicts across 121 incoming commits. The one file both sides changed is
tasks/stages/01_constructor.yml: upstream parks an applications carrier for
the swarm bootstrap roles after the add_host tasks, and keys the carrier on
the tokens file as well. This branch's mesh bootstrap opens and drops its own
block-scoped carrier before that point, so both keep working as written.

Upstream also brings ruff 0.17.0, distro-derived env keys, a scoped act outer
teardown, the stalwart relay rule, the onion apex redirect and image and
version updates; ruff 0.17 reformats nothing on this branch.
2026-10-15 21:27:11 +02:00
kevinveenbirkenbachandClaude Opus 5.5 47565781ea style(python): import datetime as dt for ruff 0.17
ruff 0.17.0 adds `datetime` -> `dt` to the default aliases of
flake8-import-conventions, and the project selects ICN, so lint-python
failed with ICN001 in seven files.

The files now import `datetime as dt`. ruff's unsafe fix also renamed
two local names in utils/github/playwright_summary.py into the alias:
the `_to_iso_z` parameter and the parsed value in
`_parse_iso_timestamp`. Both are now called `moment`; with the bare fix,
nine tests of test_playwright_summary.py failed with UnboundLocalError
and AttributeError.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:24:31 +02:00
evangelostsak a4048b43f8 fix(ci): rotate swarm rows over both mesh arms within one sweep
The mode rotation picks swarm for a row when (position + sweep) is odd, so
every swarm row of a sweep shares one position parity. wants_vpn rotated on
(position + sweep // 4) % 2, the same digit, so all swarm rows of a default
run landed on one arm: a sweep was entirely meshed or entirely direct and
never compared the two.

Reading the position's second digit (position // 2) splits the swarm rows of
every sweep about evenly between the arms, and sweep // 4 still flips each
row on its own schedule. Compose rows are untouched: they never take the
mesh.
2026-10-14 00:27:17 +02:00
evangelostsak cf9803a8a8 docs(ci): reduce the vpn variant docstrings to summary and Args
The axis is documented with the other axes, not per function.
2026-10-05 12:43:23 +02:00
evangelostsak ff75343aba fix(ci): stop a held mesh axis deleting priority rows
The pin narrowed the cross product after the axis was resolved, so a row
pinned against an enforced or disabled run lost every combination and left
the matrix entirely -- while a regular row with the same pin was simply moved
onto the held side. A priority row now takes the same precedence a regular
row already took: the run's axis first, the pin second.
2026-10-02 00:54:20 +02:00
evangelostsak 89ab678a19 feat(ci): take the run's mesh mode when assigning rows
Also moves the title codec to label.py and re-exports it, which is what keeps
the file under the line cap.
2026-09-26 08:31:11 +02:00
evangelostsak 85e9112409 refactor(ci): split the job-title codec out of the assigner
Reading a title back into its axes is the inverse of building one, and it kept
axes.py over the 500-line cap once the mesh axis landed. The names stay
importable from axes so no consumer moves.
2026-09-26 08:31:01 +02:00
evangelostsak 4be2a381ab feat(ci): let a run hold the mesh axis on one side
auto rotates, enforced meshes every swarm row, disabled meshes none. No
exclusive: every swarm row can take the mesh, so there is nothing to drop.

A run that holds the axis outranks a per-token pin, because an operator who
asked for a whole sweep on one side means it.
2026-09-26 08:30:44 +02:00
evangelostsak 997b2f5091 fix(ci): count the mesh state as a narrowing axis
Without it two tokens differing only in the mesh state compare equal and
collapse into one, and a bare role&vpn reads as narrowing nothing.
2026-09-26 02:11:40 +02:00
evangelostsak 61d5206e3b feat(ci): pin the mesh state from a selection token
role&vpn / role&direct, and the same state read back off a pasted job title.
Own separator rather than more + words: + is the onion axis, and one
separator meaning two things could not be read back unambiguously.
2026-09-25 22:34:09 +02:00
evangelostsak 3128d74ba5 feat(ci): assign the mesh axis to every deploy row
Entries carry vpn, labels carry the glyph, and the artifact slug carries the
state so a meshed and a direct run of one row cannot collide on one artifact
name. Priority rows cover both states; compose never takes the axis.
2026-09-25 22:34:04 +02:00
evangelostsak 6391761c05 feat(ci): add the WireGuard mesh deploy axis
A swarm deploy runs either over the mesh or straight over the underlay, and
both have to keep working: the mesh joins physically separated clusters, so a
single-site swarm has no use for it and must not depend on it.

Quarters on sweep // 4 so it flips in step with neither the mode rotation nor
the onion, and a row walks every mode/tor/vpn combination over eight sweeps.
2026-09-25 22:33:55 +02:00
kevinveenbirkenbachandClaude Opus 5 5b8465260f feat(ci): deploy only the variants a change reaches
An image bump in a provider redeployed every variant of every consumer,
including the variants that switch that provider off and therefore never
deploy the bumped image at all. The diff-derived whitelist now narrows each
affected role to the variants whose own round closure still reaches a seed,
emitted as `role#0,2` selection tokens. That closure is walked on the same
variant-merged services maps the matrix planner builds its rounds from, so the
narrowing follows a parent's variant choice across every generation of the
chain. It covers every auto-derived whitelist: the weekly update PRs,
Dependabot, and any other diff-driven run.

The narrowing never shrinks the matrix on partial information. A seed role
keeps every variant, because its own files changed. A role reached only
through a run_after edge keeps every variant too, because the round closure
does not follow that edge and a role whose hits are empty is therefore
unnarrowed rather than dropped.

A pinned token naming a role the discovery query does not return now warns
instead of aborting the run. The diff-derived whitelist legitimately names
roles the lifecycle envelope filters out, and once it pins variants such a
token would abort every chunk; role names are still checked against roles/
before the run starts, so a typo is caught one layer earlier.

The round's variant merge moves out of the deploy inventory package into
utils/roles/applications/variants.py. The CI selection has to build the same
maps, and importing the deploy package for them pulls in a module that reads
INFINITO_SRC_DIR at import time, which the runner host does not set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:51:03 +02:00
kevinveenbirkenbachandClaude Opus 5 4193da1cb2 fix(ci): replay the instructions on a row that runs in the guide's mode
The replay is a second deploy on the same runner, and mark() only ever checked the variant number of a row, never its mode. So the marker landed on whichever row of the smallest variant came first, which for 38 of 98 marked rows was a swarm one: that runner would have had to bring a compose stack up beside a swarm stack.

Two places needed it. The row now has to run in the mode guide_deployable names, and only rows in that mode count as deployed when the search looks for the smallest variant - without the second half the search settles on a variant this sweep runs in swarm alone and the marker falls away silently.

Measured over the full matrix: 98 marked rows become 71, swarm drops from 38 to 0, compose rises from 47 to 59. The 27 roles that lose the replay entirely are the ones this sweep deploys in no compose row of an eligible variant; getting them back means forcing such a row into the sweep, which is a change to matrix selection rather than to this marker.

mark() had no tests. It has eight now, including the two failure modes the old code showed under a negative control: a swarm row carrying instructions=compose, and a compose row left unmarked because a swarm-only variant won the search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:21:13 +02:00
kevinveenbirkenbachandClaude Opus 5 7fd495cdfc feat(ci): replay a role's README instructions on its smallest deploy row
The instructions test held a job of its own that picked one random role per
run, so a README's Production block was proven roughly once per 265 roles.
The replay now rides on a deploy row instead: no extra runner, and every role
the sweep touches gets its documented one-command deploy exercised.

The row is the role's smallest variant -- fewest services enabled -- because
that is the shape the README describes, and it is chosen among the variants
the sweep actually deploys, so a row cut as redundant coverage cannot silently
drop the role from the test. A role qualifies only when its README carries a
Production block and the guide's own mode is one the role runs and tests.

The marked entry carries the guide mode in `instructions` and a 📖 in its job
title, between the variant number and a priority ⭐. The guide mode is
independent of the mode the rotation gave the row: a swarm row still replays
in compose. The step runs last in the deploy job, after the swarm teardown,
because the replay deploys the role a second time and needs the runner's
ports.

call-test-instructions.yml, cli.meta.ci.guide_select and the `instructions`
dispatch input go with it. `redundant()` moves next to the marker so the
chunker, the plan table and the marker share one definition.

The plan table trades its 📊 Weight column for 📖 Instructions; nothing read
the weight any more, so it leaves the matrix entries too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:57:04 +02:00
kevinveenbirkenbachandClaude Opus 5 66bde6af40 feat(ci): spread distro and filesystem per row
A run-wide draw proved one distribution and said nothing about the other four, while reporting green. Both are matrix-row axes now, assigned by the same deterministic rotation mode and tor already use, so one sweep exercises every distribution and every filesystem.

The two read the row's position like an odometer, the distro as the low digit. Turning both on the position directly covers only n of the n x m pairs whenever the pools are the same length, and no sweep unlocks it.

Both are part of a row's identity: glyphs in the job label, columns in the plan table, shards in the artifact name. Both are selectable through %distro and /filesystem, and a retrigger replays the distro off a failed title. It leaves the filesystem out, because a title states the kind the matrix assigned and a deploy may have fallen back from it.

A row's filesystem is a preference unless a human names it, on the run or in a token. Reading the matrix's own narrowing as a demand would fail rows for every transient condition the applying step is built to tolerate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:23:29 +02:00
kevinveenbirkenbachandClaude Opus 5 5137465c6f style: delete every comment the comments-valid policy rejects
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.

Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:02 +02:00
kevinveenbirkenbachandClaude Opus 5 e846063f5e feat(ci): cut redundant coverage from the deploy matrix
A clone and a row an earlier one already embeds prove nothing the sweep does
not already prove, but both still took a slot. Drop them when the regular line
is chunked, so the budget buys unique coverage only, and mark them in the plan
table with a status of their own instead of letting them read as 'beyond the
budget'.

A priority row is exempt: an operator who pins a clone asked for that deploy,
and dropping it would be the silent withdrawal 6605c7ef9 just removed.

The cut sits in chunks_of, not in entries_of, so the plan still lists every
candidate while no chunk can see one. 55 of 254 rows are cut on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:26 +02:00
kevinveenbirkenbachandClaude Opus 5 0f450722bc feat(ci): start chunk 0 at the head, or wherever the operator points
The regular rows began wherever (sweep * capacity) % count pointed. On run 210
that was offset 68: chunk 0 opened at candidate 69, the coverage-first head of
the list went undeployed, and no two runs could be compared because the window
moved with the run number. A run that looked wrong turned out to be merely
rotated, and finding that out meant recomputing the arithmetic by hand.

The rotation is gone. Chunk 0 reads the ranking from the top unless an offset
says otherwise, and the offset is an input now, threaded from the manual form
through the orchestrator and the deploy call down to the matrix builder. The
plan renderer takes the same flag, so the table shows the window the run will
take.

The offset is a row count or a selection token. A token is the honest form:
the ranking shifts whenever a variant appears, so 'offset 84' means something
different tomorrow while 'web-app-nextcloud#5' still means the same row. Axes
the token leaves open match anything, so a bare role name starts at its first
row and a pasted job label starts at exactly the job it names. A token that
matches no regular row aborts rather than silently starting at the head an
operator meant to skip.

The run title carries it as ⏩ followed by the value, and only when one was
asked for, so a rotated-looking run says so in its own name and the retrigger
reads it back from there when a job log has expired.

resume_from_chunk leaves the manual form with it. Skipping whole chunks was
the coarse way to say 'carry on further down'; the offset says it exactly and
keeps the rest of the chain intact. The orchestrator input stays for the other
entry points.

What this gives up is automatic tail coverage. Rows beyond the budget are
reached by moving the offset, widening the budget or narrowing the selection,
not by waiting for consecutive sweeps to walk the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:50 +02:00
kevinveenbirkenbachandClaude Opus 5 52c1162284 fix(ci): deploy a row once per token that names it
Two tokens naming one row is the case the axes exist for: a variant that
failed in compose and in swarm comes back as web-app-x#1@compose+tor and
web-app-x#1@swarm+tor, and both have to deploy. The applier could not express
it. It walked the rows, took the first pin that matched and broke out, so the
second token matched nothing, counted as a typo, and aborted the chunk's
discovery with 'matches no discovered row'.

Two CI runs died on it before the cause was clear, both reporting a token that
was perfectly valid.

Pins and rows are now paired both ways round: every pin that matches a row
yields its own entry. Two tokens that narrow a row identically collapse into
one, so a duplicate in the input cannot become two jobs racing for a single
artifact name, and a bare role name loses to any token that narrows the same
row -- asking for role#1@swarm should not also deploy a rotation-picked
variant of that role.

The old unit test paired two pins on two different variants, which is why it
stayed green; the collision on one variant now has its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:49 +02:00
kevinveenbirkenbachandClaude Opus 5 b058415eb3 feat(ci): name the row that already covers a planned deploy
The plan told you a row is being deployed, never that another row in the same
sweep already proves whatever it would prove. The complexity matrix has always
known -- covered_by holds the id of the earlier row that embeds this one -- but
the number stopped at the matrix, and 46 of 256 rows carry one.

The matrix entry now carries the row's discovery id and that covering id
through to the plan, which renders them as two columns and drops them again
before the matrix reaches the deploy jobs.

The id column had to change with it. It was a running count over the rendered
lines, so a covered_by of 1 pointed at a row that numbering does not have. It
is now the discovery id, the same number the complexity matrix prints, which is
what makes the reference resolvable inside the table it appears in.

Two tests indexed their columns by position and broke on the new one; they ask
_COLUMNS for the index now, so the next column does not break them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:45 +02:00
kevinveenbirkenbachandClaude Opus 5 111b1bbcdf feat(ci): pin deploy axes from a selection token
A whitelist or priority entry was a role name and nothing else, so a run
could not ask for "this variant, on swarm, behind the onion". It either
covered everything the role offers or waited for the rotation to come
around to the combination that broke.

An entry is now a token that may carry the axes, either as
web-app-x#0,2@swarm+tor or as the job label pasted straight back out of
the run that failed. What a token leaves open still belongs to the line
it stands in: priority covers every remaining combination in one sweep,
whitelist keeps the rotation and its position in the matrix. A pin the
row cannot take aborts the matrix rather than dropping the row, because
a dropped row reports a green run for a combination that never ran.

The onion state spells out as +clearnet, not as a -tor suffix: svc-net-tor
would otherwise read as role svc-net on the clearnet.

Chunk membership still follows the discovery ranking, which is what
decides who makes the budget cut, but the rows inside a chunk are sorted
by display name, variant, mode and onion state, so a chunk's job list
reads like the plan table instead of like the sweep's rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:42 +02:00
kevinveenbirkenbachandClaude Opus 5 efb5730d68 feat(ci): give host rows a glyph and the variant a separator
The second glyph was emitted only for the modes that carry a tor axis, so host rows were the only ones reading with a single marker. LOCAL_GLYPH fills that position. 3.5 was already the glossary's symbol for host, bound to the word test_host, and symbol_glossary refuses to import when two words share a glyph -- so the axis binds the existing definition instead of writing the character out and stepping past that guarantee.

The variant now joins with # instead of a space: a display name and its shard were two whitespace-separated tokens, and decode_list splits on whitespace, so 'tor 1' parsed as two names. One label, one identifier.

encode builds it, _VARIANT_SUFFIX strips it and LABEL_RE reads it back -- all three now go through the same constant, which is what keeps the parser from drifting away from the builder it is the inverse of. Legacy titles are deliberately not accepted: a second tolerated format is exactly that drift. An old title now leaves the shard inside the name and decode returns None, so the job reads as unrecognised rather than as the wrong role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:39 +02:00
kevinveenbirkenbachandClaude Opus 5 f8fa87c185 fix(ci): stop the tor row disabling itself
Every clearnet matrix row carried disable=tor, including the row whose own app is the tor provider. init.py resolves that token to svc-net-tor, prunes it from the primary list and aborts with 'All primary apps disabled', so the job died at rc=1 having deployed nothing: the rescue snapshot captured 0 containers and 0 services.

Blanking the disable was not an option. disable is the only lever the deploy reads (tor: false reaches nothing but a glyph, a dedup key and a concurrency group), so a blanked row would have been byte-identical to the tor row while reporting a clearnet check. The provider's onion state is not a row property, so its rows now yield no clearnet state at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:37 +02:00
kevinveenbirkenbachandClaude Opus 5 dc6fbe24d9 feat(ci): give every variant its own row with rotating deploy axes
Variant bundling packed several variants onto one runner and made the
job count depend on storage sums. Every variant now gets its own matrix
row instead, and the two things that used to be fixed per workflow --
which deploy mode a row runs in, and whether it sits behind the node
onion -- become axes assigned per row.

Both axes are a deterministic rotation over the row's position and the
sweep number, never random, so a red job reproduces by re-running the
same sweep. Tor turns on sweep//2 so it does not flip in lockstep with
the mode: a row walks all four mode/tor combinations over four sweeps
instead of only two.

A role offers at most two modes in practice, since swarm requires its
own stack and host requires the absence of one.

Drops with the bundler: limits.py, the variant_bundle env handlers,
INFINITO_VARIANT_BUNDLE_SIZE/_MAX_STORAGE, the per-role
meta/tests.yml variant_bundle_size key and its only user, and the
ComplexityRow bundles/jobs fields with the --deploy-mode and --max-jobs
flags that consumed them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:04 +02:00
kevinveenbirkenbachandClaude Opus 5 bc1d2d86f6 feat(ci): carry the tor mode into a retrigger, read from the job log
A retrigger reproduces its source run's configuration, and the tor axis was
missing from it: rerunning a run dispatched with 'exclusive' silently fell back
to 'auto' and tested a different matrix than the one whose failures it was meant
to chase.

The mode is read from the job log's Inputs block, not from the run title. The
title renders it as a glyph, so recovering it there would mean translating a
symbol back into a word on every retrigger; the log already holds what the
operator picked, and inputs_from_jobs reads that same block for the priority
list today. LOG_INPUTS names the inputs that take this route.

That also removes a trap the glyph created. The tor segment renders through
format('{0} ', ...) with no opening literal, and run_name locates a value by
searching for its head - str.find('') matches at position 0, so the parser would
have handed back the title's own prefix as the tor value, plausibly and without
raising. segments() now skips a headless segment; its glyphs stay openings, so
they still terminate the value before them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:22:20 +02:00
kevinveenbirkenbachandClaude Opus 5 bb2b28a1a6 feat(ci): mark clearnet jobs too, and add enforced and exclusive tor modes
The onion glyph alone left a job name ambiguous: no glyph meant either
'clearnet' or 'this mode has no tor axis'. Swarm and compose entries now open
with the onion or the globe, so the name states which of the two networks the
job tested. Host keeps neither — it shares the matrix builder but has no onion,
and a glyph there would claim something the job never did.

'enabled' becomes 'enforced' so the manual selector says what it does: tor on
every role that can take one, not tor on everything.

'exclusive' is the new mode. keep_tor_variants runs before assign_tor and cuts
each entry down to its tor-capable variants, dropping entries left with none, so
a run tests the onion and nothing else. A bundle can come out with a hole in it
— variants 0 and 2 capable, 1 not, yields '0,2' — which filter_plan_to_variants
already handles by set membership; both shapes are now pinned by tests.

Note the word is overloaded: services.tor.exclusive drops a deployed app's
clearnet vhost, while this mode only narrows what CI runs. The docstring says so
where a maintainer will look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:18:10 +02:00
kevinveenbirkenbachandClaude Opus 5 ddfb86e650 feat(ci): give the deploy matrix a tor axis with an onion marker
Tor coverage was decided per role variant and nowhere else: 1d9d355bc pins
services.tor.enabled true in variant 0 and false in every other variant, so an
operator had no way to steer it and a job name never said whether it ran behind
an onion. INFINITO_TOR now controls the axis from manual steer down to the
drill, and the entries that get an onion wear one in their label.

assign_tor decides per entry. 'enabled' gives tor to every capable entry,
'disabled' to none, 'auto' to every second one with the run number's parity
flipping which one starts — measured over the full matrix: 397 entries, 257
capable, 129 on even runs, 128 on odd, union 257, overlap 0. Two consecutive
runs therefore cover every capable entry exactly once.

tor_capable reads the entry's own variants, not just meta/services.yml.
Reading only the base config claimed an onion for 131 of 388 entries whose
variant had pinned the gate off — a third of the glyphs would have lied. A
bundle counts as capable when any variant it covers can use tor; a reactive
Jinja flag counts as capable, since that is the role saying 'onion when the
node is dark'.

The 'disable' token is the working half: it prunes svc-net-tor from the include
closure (utils/tests/swarm/derive_includes.py) and clears the service flags in
host_vars (cli/administration/inventory/provision/services_disabler.py). Both
halves are required — with only the first, extend_inventory walks the provider
back in. It runs after the variant overlay, so it also overrides a variant that
pinned the gate true.

Only swarm and compose join the axis. Host discovery shares the same matrix
builder, so without the allowlist its jobs would carry onion glyphs for a mode
that has no onion at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:13:19 +02:00
kevinveenbirkenbachandClaude Opus 5 71888d86af refactor(ci): prefix workflows by trigger
The workflow directory mixed entry points, reusable stages and scheduled jobs into one flat list sorted by topic, so the first question about any file - what starts it - was the one the name did not answer. Every workflow now carries one of three prefixes, derived from its on: block: cron- for a schedule trigger, call- for one only reachable through workflow_call, entry- for everything a repository event or a human starts. Scheduled workflows additionally carry an alarm clock as the first character of their name.

Two lints keep it that way. One derives the expected prefix from the triggers and compares it against the file name; it flagged all 17 offenders before the rename and none after. The other resolves every .github/workflows/<file> string in the repository against the directory, which is what a rename silently breaks in Markdown links, Python constants and shell API URLs alike.

Renaming pulled 69 pre-existing comments into the comment lint's changed-file scope. The vendored CodeQL template got the nocheck marker its scorecard sibling already carried, two workflow headers moved above name: where they count as file headers, and the remaining banners were deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:05:51 +02:00
kevinveenbirkenbachandClaude Opus 5 add00234c9 feat(ci): name deploy jobs after the role, not its id
CI deploy jobs were titled with the raw role id, so a run page read as a
column of dash-joined prefixes. They now carry a display name: the category
path as hanzi labels, then the role's README heading or the part of the id
the categories do not already say, whichever is shorter.

utils/roles/display.py owns both directions. Encoding happens once, at
discovery time, as a `label` key on every matrix entry; the workflows render
`<glyph> ${{ matrix.label }}` and keep the glyph as a literal because the
retrigger reads the deploy mode off it.

Decoding is what makes the retrigger keep working. It accepts a display name
with or without its variant shard and a bare role id unchanged, so job names
from runs that predate this still parse and a hand-typed dispatch never has
to spell hanzi. The parser therefore stopped matching an id-shaped tail and
asks the codec instead; a job the codec does not recognise is skipped rather
than counted as a role.

The role selections go out as display names too, so the run title reads like
the jobs it spawns. Four consumers decode them back, each the single place
the value is actually used: the whitelist resolver before it checks the role
directory, the discovery query on its env, and the sequencing and plan CLIs
on their arguments.

Every fragment is joined by `·`, including before the title, and the heading
loses its whitespace and any emoji variation selector. A display name holding
a space would make the space-separated whitelist and priority inputs
unparseable with more than one role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 3883d73c43 feat(ci): retrigger reproduces the source run
The REST API answers inputs: null for a finished workflow_dispatch, so
the run name is the only record of what a manual run was dispatched
with. It carried one value, distros; everything else silently fell back
to the workflow defaults, and a failure that only reproduces on swarm
with btrfs came back green from a run that deployed all three modes on
whatever filesystem it drew.

Render every input as its own <emoji><value> segment instead, omitted
when it holds its default, so the title carries exactly what deviates
and an absent input means 'leave it on the default'. run_name derives
those segments from the workflow rather than knowing them, so the format
cannot drift away from the parser without a failing test.

distros no longer defaults to debian -- empty draws one at random, the
way untagged pushes already do. That leaves nothing in the title, so the
effective list is recovered from the discover job names, matched by
content against the meta/distros.yml SPOT rather than by job-name shape.

Priority roles that fell behind the budget cut in every mode hold no job
at all, so failed_roles cannot see them: they are neither green nor red,
they never ran. Carry them along or they never deploy.

--run now resolves before the selection, so it reproduces a run whatever
picks the roles, not only with --failed.

tests/utils/ci_*.py move into tests/utils/ci/ -- two files sharing a
delimiter-bounded prefix are a folder in disguise, and the structure
lint says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:40 +02:00
kevinveenbirkenbach 93eea4c415 Autoformat 2026-08-04 13:27:35 +02:00
kevinveenbirkenbachandClaude Fable 5 65d2aa028e feat(ci): emoji entry names and a symbol-framed manual run title
The three CI entry workflows drop the "⚡ CI:" prefix for one emoji each -
🕹️ Manual, 🔀 Pull Request, 📤 Push - completing the trigger triad by symbol.
The manual run-name loses its prose prefix and separator dots; the segments now
frame themselves: 🕹️ for the dispatch, 🐧 before the distro sweep, ⭐ for the
priority line, 🎯 for a verbatim whitelist, 🔀 for diff derivation.

  before: Manual CI triggered: debian arch; web-app-x ⭐; diff-derived (origin/main)
  after:  🕹️ 🐧 debian arch ⭐ web-app-x 🔀 diff (origin/main)

Dropping the dots removes the fixed literal after inputs.distros that
utils/github/run_name.py cut the distro list at - the list is itself
space-separated, and the next segment starts with a branch-dependent emoji.
value_from_title therefore derives the terminator dynamically when the framing
literal is whitespace-only: _openings_after collects what any later template
segment can render first (the leading literal of each quoted string in later
expressions, format placeholders stripped, plus later inter-expression
literals) and cuts at the earliest occurrence. The SPOT property holds: the
openings are read from the workflow's own run-name, so a future emoji change
flows into the parser without touching it.

Titles from before this change no longer match the template and yield '' - the
documented unknown-title behaviour, so a retrigger of a pre-rename run falls
back to the workflow's distro default instead of guessing.

The dispatch CLI is unaffected (it targets entry-manual.yml by file name), job
names and the orchestrator are untouched, and no workflow_run trigger
references the old names. Required status checks configured against the old
workflow names in repository settings are the one surface this repo cannot
see - re-check them after the rename lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:27:34 +02:00
kevinveenbirkenbachandClaude Opus 5 6c816e893e refactor(github): group the variant modules and drop the blind-except
variant_bundle_limits.py next to variant_bundles.py formed the prefix group
'variant', which the structure lint reads as a folder in disguise. It stayed
green locally because that lint asks git ls-files: the new file was untracked
through every quality run and only became visible once committed. CI saw it
immediately - the sole failure of run 30675284663.

Both move to utils/github/variant/ as bundles.py and limits.py, and the test
mirrors along.

The BLE001 per-file ignore is removed rather than repointed. parse_size raises
humanfriendly.InvalidSize for a malformed string and TypeError only for a
non-string, and the value reaching it is always str - so the catch-all was
never needed. Measured, not assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 02:46:36 +02:00
kevinveenbirkenbachandClaude Opus 5 0eb04b2239 feat(ci): retrigger failed roles on the distros the source run swept
The retrigger sent only whitelist and priority, so entry-manual.yml applied its
own distros default of 'debian'. A run that swept five distros came back as one,
and a failure that only reproduces on centos returned green with nothing fixed.

The dispatch inputs are not recoverable from the API - it answers inputs: null
for a finished workflow_dispatch - so they are read back from the run title.
The title format is not copied here: run_name.py parses the workflow's own
run-name declaration and derives the literals framing an input, and the tests
build their titles through the same helper instead of spelling one out.

Known gap: a run from another entry point carries an unrelated title and yields
no distros, leaving the retrigger on the workflow default. Job step names carry
the list for every entry point and arrive in the same gh call, so that is where
to look when it matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 02:13:31 +02:00
kevinveenbirkenbachandClaude Opus 5 82a5eb83f2 fix(env): keep the bundle defaults on the bare bootstrap layer
Registering variant_bundle/max_storage in ORDERED_HANDLERS was right - the key
had never reached a generated .env - but the handler imported its default from
utils.github.variant_bundles, and that module pulls humanfriendly and, through
utils.cache.applications, yaml. The .env generator imports every registered
handler and has to run on the bare bootstrap python, so one constant import
took it down.

Move both defaults into a stdlib-only module. This also ends a duplication the
handler layer had already been carrying: size.py spelled out '3' while
variant_bundles held the same 3 - the very thing max_storage tried to avoid by
importing, at the cost of the layer boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 02:13:31 +02:00
kevinveenbirkenbachandClaude Opus 4.8 09b0fdb35f fix(ci): mark the CI log and junit readers lint-clean
logparse.parse_log does a one-shot read of a single deploy log with no
reuse, so the cached-read requirement does not apply; annotate the read
with a cache-read nocheck. The pytest_summary docstring shows the script
path as a usage example, not self-referential logic; annotate it with a
self-path-reference nocheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:39:28 +02:00
kevinveenbirkenbachandClaude Opus 4.8 4a398653aa feat(ci): per-target and per-test summaries for the lint and test jobs
parallel.sh mirrors its per-target wall-clock table into
GITHUB_STEP_SUMMARY, so make lint and make test render a target/status/
duration overview on the run page. The pytest runner writes junit XML
per suite into build/test-reports and test.yml renders them via
utils/github/pytest_summary.py: headline counts with total time,
failures opened with durations, every test collapsed slowest-first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 02:39:28 +02:00