feat(ci): collapse the four deploy workflows into one chunk chain

call-test-deploy-{compose,host,swarm,single-node}.yml differed only in
which mode they pinned and which artifact prefix they wrote; compose and
host already ran the identical drill. They become one call-test-deploy.yml
whose matrix rows carry the mode, so the swarm-specific steps are guarded
on it and everything else is shared.

The orchestrator loses its eight deploy jobs and the sequencing job for
three serial chunk blocks. GitHub Actions cannot generate a variable
number of jobs, so the blocks are written out and INFINITO_CI_MAX_CHUNKS
tells the budget how many exist; blocks whose slice is empty skip
themselves. chunk_gate stops the chain at the first red chunk,
resume_from_chunk re-enters after a fix instead of re-running the green
chunks, and modes collapses to a single mode choice.

apps.sh now emits matrix entries for one chunk rather than a token list
for one mode, and no longer sorts them: the discovery order is what the
chunker slices on. split_runner_apps.sh reads its runner quota from
INFINITO_CI_CONCURRENCY instead of carrying a second copy of the 20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-15 06:09:04 +02:00
1 parent 5009ec81c9
commit db66e01020
16 files changed
+432 -1211

No files matched your search

+97 -109
View File
@@ -28,32 +28,21 @@ flowchart TB
codeql["cron-security-codeql.yml"]
buildci["build-ci-images: images-build-ci.yml"] --> dns["test-dns.yml"]
mirror["images-mirror-missing.yml"]
seq["sequencing: serial or parallel, per line"]
subgraph prio["priority line, skipped without a priority input"]
swarmprio["test-deploy-swarm-priority"]
swarmprio -->|"serial"| composeprio["test-deploy-compose-priority"]
composeprio -->|"serial"| hostprio["test-deploy-host-priority"]
snprio["test-deploy-single-node-priority: parallel"]
subgraph chain["serial chunk chain"]
chunk0["test-deploy-chunk-0"]
chunk0 --> chunk1["test-deploy-chunk-1"]
chunk1 --> chunk2["test-deploy-chunk-2"]
end
subgraph reg["regular line"]
swarmreg["test-deploy-swarm"]
swarmreg -->|"serial"| composereg["test-deploy-compose"]
composereg -->|"serial"| hostreg["test-deploy-host"]
snreg["test-deploy-single-node: parallel"]
end
lintwf --> chain
testwf --> chain
dns --> chain
mirror --> chain
buildci --> chain
lintwf --> prio
testwf --> prio
dns --> prio
mirror --> prio
seq --> prio
prio -->|"all priority jobs green"| reg
swarmreg --> smoke["test-runner-smoke.yml"]
prio --> report["report-main-failures"]
reg --> report
chunk0 --> smoke["test-runner-smoke.yml"]
chain --> report["report-main-failures"]
instmake["test-install-make.yml"]
instpkgmgr["test-install-pkgmgr.yml"]
@@ -61,9 +50,7 @@ flowchart TB
buildci --> testguide["test-instructions.yml"]
mirror --> testguide
prio --> donegate["done"]
reg --> donegate
seq --> donegate
chain --> donegate["done"]
smoke --> donegate
instmake --> donegate
instpkgmgr --> donegate
@@ -71,111 +58,112 @@ flowchart TB
testguide --> donegate
end
snprio --> singlenode["test-deploy-single-node.yml"]
snreg --> singlenode
singlenode --> deploycompose["test-deploy-compose.yml"]
singlenode --> deployhost["test-deploy-host.yml"]
composeprio --> deploycompose
composereg --> deploycompose
hostprio --> deployhost
hostreg --> deployhost
swarmprio --> deployswarm["test-deploy-swarm.yml"]
swarmreg --> deployswarm
chunk0 --> deploy["call-test-deploy.yml"]
chunk1 --> deploy
chunk2 --> deploy
```
The priority line runs only when the orchestrator's `priority` input is set;
with it empty every priority job is skipped and the regular line starts
directly. The regular jobs receive the priority ids as `blacklist`, so each
role deploys in exactly one line.
## Sweeps and chunks
## Deploy-line sequencing
A **sweep** is one orchestrator run. It builds a single ordered list of
`role#variant` rows, assigns each row a deploy mode and a tor state, and
deploys the list in serial **chunk** blocks.
A **line** is one half of a run: the priority line (`⭐`, the roles named in
`priority`) or the regular line (`🔁`, everything else). Each line deploys up
to three **modes**: swarm, compose, host. The lines are always sequential
relative to each other; this section is about the order *inside* one line.
### Why the order exists
### Why chunks exist
GitHub cancels a job that has sat queued for 24 hours. That clock starts when
the job is queued, not when the run starts, and a `needs:` edge delays queueing
until the dependency completes. A line that starts all three modes at once
queues its whole matrix in one go, so with more roles than runner slots the
tail of that matrix waits past the cut and dies unrun. Deploying the modes one
after the other restarts the clock per mode.
until the dependency completes. A run that queues its whole matrix at once
leaves the tail waiting past the cut, where it dies unrun. Deploying in chunks
chained on `needs:` restarts the clock per chunk.
Serialising costs wall-clock, so it is only worth it above a certain size. The
run does not pay it on a small diff.
The chunk size is what makes that safe, and `cli.meta.ci.slots` derives it
rather than pinning it:
### The decision
```text
waves = floor(INFINITO_CI_QUEUE_HOURS / the deploy job's timeout-minutes)
chunk size = INFINITO_CI_CONCURRENCY * waves
```
The `sequencing` job resolves the effective whitelist (the same
`scripts/github/resolve/effective_whitelist.sh` the discover steps use) and
calls `cli.meta.ci.sequencing` once per line, emitting `serial` or `parallel`
as a job output.
Assuming every job burns its full timeout is deliberately pessimistic — no job
can outlast it — so the estimate can only overshoot the real drain time. With
the current constants that is 4 waves of 20 runners, so 80 rows per chunk, and
the last job of a chunk starts around t=18h, well inside the 24h window.
Job counts are taken on the row basis each mode actually runs on: swarm
selections are `role#variant` tokens that map 1:1 onto jobs, compose and host
selections are whole roles whose variants pack into bundles
(`utils.github.variant.bundles`). The count deliberately omits the
runner-storage filter that `scripts/meta/resolve/apps.sh` applies inside
GitHub Actions, so it can only overestimate — erring towards the sequential
layout, which cannot be cancelled.
### How many chunks a sweep spends
| `sequencing` input | Behaviour |
The run's 256-job cap is the second ceiling. `slots` counts every non-deploy
job the orchestrator chain spawns, subtracts the worst `entry-*.yml` overhead,
and what remains is `available` — the rows one sweep may deploy across all its
chunks. Rows beyond that roll into the next sweep, which starts reading the
regular line at an offset (`cli.meta.ci.chunks`), so consecutive sweeps walk
the whole list instead of re-testing the same head forever.
GitHub Actions cannot generate a variable number of jobs, so the chunk blocks
are written out in the orchestrator. `INFINITO_CI_MAX_CHUNKS` must equal how
many exist there — `slots` plans a sweep against that key, and a sweep planned
larger than the chain silently drops its tail. Blocks whose slice comes back
empty skip themselves and cost nothing.
Run `python -m cli.meta.ci.slots --matrix` to see the whole budget.
### Priority
`priority` names the roles that lead. They sort to the head of the list and the
split forces a chunk boundary at the priority/regular seam, so a chunk is
either all priority or all regular — the seam chunk stays short rather than
being topped up. That is what guarantees every priority row is deployed before
the first regular one starts. Priority rows wear a trailing `⭐` in their job
name and never move with the sweep offset.
They are also the rows a run must not sample. A priority row is deployed in
**every combination it can take** — each variant, in each mode its role offers,
and on the modes that carry the onion axis once behind Tor and once on
clearnet — all within the same sweep. A 3-variant role offering compose and
swarm therefore becomes 3 × 2 × 2 = **12 jobs**, not 3. That is the point of
naming a role in `priority`: it is proven everywhere at once instead of over
four sweeps.
An explicit `tor` input still wins over the full coverage: `enforced`,
`exclusive` and `disabled` are operator narrowings, and a variant that pins
`services.tor.enabled` to false never gets an onion run regardless.
### Mode and tor
For **regular** rows both axes are a deterministic rotation over the row's
position in the global list and the sweep number, never random, so a red job
reproduces by re-running the same sweep:
| Axis | Rotation |
|---|---|
| `auto` (default) | `serial` above `INFINITO_CI_SEQUENTIAL_THRESHOLD` jobs in that line, `parallel` at or below it |
| `serial` | forced; skips the count entirely |
| `parallel` | forced; skips the count entirely |
| mode | `(position + sweep) % len(modes the role offers)` |
| tor | `(position + sweep // 2) % 2` |
The threshold lives in [`default.env`](../../default.env) as
`INFINITO_CI_SEQUENTIAL_THRESHOLD`. Both lines are decided independently: a
small priority line can run parallel while the regular line behind it runs
serial.
A role offers at most two modes in practice — swarm needs its own stack, host
needs the absence of one — so a row flips between its two modes on consecutive
sweeps. Tor turns on `sweep // 2` so it does not flip in lockstep: a row walks
all four mode/tor combinations over four sweeps instead of only two. Priority
rows skip the rotation entirely and take the whole cross-product at once.
### The two layouts
Serial order is **swarm, then compose, then host** — heaviest mode first, so
the longest queue drains while the run is youngest.
- **parallel** — `test-deploy-swarm-priority` plus
`test-deploy-single-node-priority`, which fans compose and host out
together. This is the layout the pipeline had before sequencing existed.
- **serial** — `test-deploy-swarm-priority` →
`test-deploy-compose-priority` → `test-deploy-host-priority`, each chained
on the previous through `needs:`.
The regular line mirrors this with the unsuffixed job names.
A `needs:` edge is static, so a workflow cannot make one conditional. Both
layouts therefore exist as separate jobs and the `sequencing` output skips
one of them. Because a skipped dependency otherwise skips its dependents, the
serial jobs carry `always()` plus explicit `needs.<job>.result` checks on the
real gates. The swarm job depends on no sequencing decision at all: it leads
in either layout.
Because the same variant can now run twice in one sweep, the onion state is
part of what identifies a job: it is in the job label (`🧅` vs `🌐`), in the
deploy concurrency group, and in every artifact name. Two jobs uploading under
one artifact name is a conflict, not an overwrite.
### Stopping on failure
`mode_fail_fast` (default `true`) decides whether a serial line stops at its
first failed mode:
`chunk_gate` (default `true`) decides whether the chain stops at its first
failed chunk:
| Value | Behaviour |
|---|---|
| `true` | swarm fails → compose and host of that line are skipped |
| `false` | every mode deploys and reports; the run still ends red |
| `true` | chunk 0 fails → the remaining chunks are skipped |
| `false` | every chunk deploys and reports; the run still ends red |
`skipped` counts as passed, so a mode absent from `modes` never blocks the
chain. The parallel layout ignores the switch — with no order there is nothing
to stop. The setting is a checkbox on `entry-manual-steer.yml`; the other entry
points take the default.
### Job budget
`cli.meta.ci.slots` divides the run's 256-job cap between the deploy
matrices and reads the orchestrator to do it, so every deploy caller —
including the serial twins — must be listed in its `_DEPLOY_CALLERS`.
A caller missing there is charged a guessed dynamic-matrix estimate instead
of its single discover job.
`skipped` counts as passed, so an empty chunk never blocks the chain. After
fixing what broke a sweep, `resume_from_chunk` re-enters at that index instead
of re-running the green chunks. Both are inputs on `entry-manual-steer.yml`;
the other entry points take the defaults.
## Cancellation
@@ -209,5 +197,5 @@ flowchart TB
Also manually dispatchable: `cron-images-mirror-all.yml`, `cron-images-cleanup-ci.yml`,
`cron-cleanup-stale.yml`, `cron-update.yml`, `cron-release-highest.yml`, `call-release-version.yml`,
`call-lint.yml`, `call-test.yml`, `call-test-deploy-swarm.yml`, `call-test-dns.yml`,
`call-lint.yml`, `call-test.yml`, `call-test-dns.yml`,
`test-workspace.yml`, `test-runner-smoke.yml`.
+75 -285
View File
@@ -16,23 +16,28 @@ on:
required: false
type: string
default: ""
modes:
description: "Deploy modes to run (space-separated: 'swarm compose host'). Only listed modes run their deploy job."
required: false
type: string
default: "swarm compose host"
priority:
description: "Optional space-separated app ids for the priority line: deployed first in every mode; the regular line runs the remaining roles only after every priority job succeeded."
required: false
type: string
default: ""
sequencing:
description: "🐛 Deploy-mode order per line. 'serial': swarm, then compose, then host. 'parallel': all at once. 'auto': serial above the job threshold."
mode:
description: "Deploy modes rows may be drawn from: 'auto' rotates every row over the modes its role offers; a subset of 'host compose swarm' keeps only the rows those modes can take."
required: false
type: string
default: "auto"
mode_fail_fast:
description: "🛑 Stop a serial line at its first failed mode. Off: keep deploying the remaining modes."
priority:
description: "Optional space-separated app ids for the priority line: sorted to the head of the list and cut into chunks of their own, so every priority row is deployed before the first regular one starts. Priority rows skip the sweep rotation and run every combination they can take (each variant, each offered mode, Tor and clearnet)."
required: false
type: string
default: ""
sweep:
description: "Sweep number driving the mode/tor rotation and the regular-line offset. Empty: the run number."
required: false
type: string
default: ""
resume_from_chunk:
description: "⏭️ Skip every chunk below this index. Use to continue a sweep after fixing what broke it, instead of re-running the green chunks."
required: false
type: number
default: 0
chunk_gate:
description: "🛑 Stop the chunk chain at its first failed chunk. Off: keep deploying the remaining chunks."
required: false
type: boolean
default: true
@@ -67,7 +72,7 @@ on:
type: string
default: "auto"
tor:
description: "Tor axis of the swarm and compose matrices: 'auto' onions every second tor-capable entry and flips with the run number, 'enforced' onions all of them, 'exclusive' also drops the entries that cannot, 'disabled' onions none."
description: "Tor axis of the deploy matrix: 'auto' rotates the onion over the tor-capable rows, 'enforced' onions all of them, 'exclusive' also drops the rows that cannot, 'disabled' onions none."
required: false
type: string
default: "auto"
@@ -171,7 +176,7 @@ jobs:
distros: "arch debian ubuntu fedora centos"
concurrency_channel: ci-orchestrator-build-consumer
secrets: inherit
test-dns:
needs:
- build-ci-images
@@ -179,7 +184,7 @@ jobs:
uses: ./.github/workflows/call-test-dns.yml
with:
distros: "arch debian ubuntu fedora centos"
images-mirror-missing:
permissions:
contents: read
@@ -191,305 +196,101 @@ jobs:
image_wait_sleep_seconds: ${{ inputs.image_wait_sleep_seconds }}
secrets: inherit
sequencing:
name: 🐛 Sequencing
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
priority: ${{ steps.priority.outputs.sequencing }}
regular: ${{ steps.regular.outputs.sequencing }}
env:
INFINITO_APP_DISCOVERY_RUNNER: host
INFINITO_LIFECYCLES: ${{ inputs.lifecycles }}
INFINITO_HOST_EXCLUDE_COMPOSE: ${{ contains(inputs.modes, 'compose') && 'true' || 'false' }}
steps:
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
- name: 🔧 Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: 📦 Install project (host venv)
shell: bash
run: make install
- name: 🔍 Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve/effective_whitelist.sh
- name: ⭐ Decide the priority line
id: priority
if: ${{ inputs.priority != '' }}
shell: bash
run: |
source scripts/meta/env/load.sh
"${PYTHON}" -m cli.meta.ci.sequencing \
--modes "${{ inputs.modes }}" \
--whitelist "${{ inputs.priority }}" \
--lifecycles "${{ inputs.lifecycles }}" \
--choice "${{ inputs.sequencing }}" \
| tee -a "$GITHUB_OUTPUT"
- name: 🔁 Decide the regular line
id: regular
shell: bash
run: |
source scripts/meta/env/load.sh
"${PYTHON}" -m cli.meta.ci.sequencing \
--modes "${{ inputs.modes }}" \
--whitelist "${{ steps.resolve_wl.outputs.whitelist }}" \
--blacklist "${{ inputs.priority }}" \
--lifecycles "${{ inputs.lifecycles }}" \
--choice "${{ inputs.sequencing }}" \
| tee -a "$GITHUB_OUTPUT"
test-deploy-swarm-priority:
name: ⭐ Priority · swarm
if: ${{ inputs.priority != '' && contains(inputs.modes, 'swarm') }}
needs:
- lint
- test
- build-ci-images
- images-mirror-missing
- fork-prereqs-ready
uses: ./.github/workflows/call-test-deploy-swarm.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
filesystem: ${{ inputs.filesystem }}
tor: ${{ inputs.tor }}
marker: " ⭐"
test-deploy-single-node-priority:
name: ⭐ Priority · single-node
if: >-
${{ inputs.priority != ''
&& needs.sequencing.outputs.priority == 'parallel'
&& (contains(inputs.modes, 'compose') || contains(inputs.modes, 'host')) }}
needs:
- test-dns
- images-mirror-missing
- lint
- test
- sequencing
uses: ./.github/workflows/call-test-deploy-single-node.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
filesystem: ${{ inputs.filesystem }}
modes: ${{ inputs.modes }}
tor: ${{ inputs.tor }}
marker: " ⭐"
test-deploy-compose-priority:
name: ⭐ Priority · compose
test-deploy-chunk-0:
name: 🧱 Chunk 0
if: >-
${{ always()
&& inputs.priority != ''
&& needs.sequencing.outputs.priority == 'serial'
&& contains(inputs.modes, 'compose')
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.test-dns.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& (!inputs.mode_fail_fast
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm-priority.result)) }}
needs:
- test-dns
- images-mirror-missing
- lint
- test
- sequencing
- test-deploy-swarm-priority
uses: ./.github/workflows/call-test-deploy-compose.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
filesystem: ${{ inputs.filesystem }}
tor: ${{ inputs.tor }}
marker: " ⭐"
test-deploy-host-priority:
name: ⭐ Priority · host
if: >-
${{ always()
&& inputs.priority != ''
&& needs.sequencing.outputs.priority == 'serial'
&& contains(inputs.modes, 'host')
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.test-dns.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& (!inputs.mode_fail_fast
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose-priority.result)) }}
needs:
- test-dns
- images-mirror-missing
- lint
- test
- sequencing
- test-deploy-compose-priority
uses: ./.github/workflows/call-test-deploy-host.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
filesystem: ${{ inputs.filesystem }}
marker: " ⭐"
exclude_compose_covered: ${{ contains(inputs.modes, 'compose') && 'true' || 'false' }}
test-deploy-swarm:
name: 🔁 Regular · swarm
if: >-
${{ always()
&& contains(inputs.modes, 'swarm')
&& inputs.resume_from_chunk <= 0
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.build-ci-images.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-single-node-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-host-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm-priority.result) }}
&& needs.test-dns.result == 'success' }}
needs:
- lint
- test
- build-ci-images
- images-mirror-missing
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- lint
- test
uses: ./.github/workflows/call-test-deploy-swarm.yml
- test-dns
- fork-prereqs-ready
uses: ./.github/workflows/call-test-deploy.yml
with:
distros: ${{ inputs.distros }}
index: "0"
sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }}
whitelist: ${{ inputs.whitelist }}
priority: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
modes: ${{ inputs.mode }}
filesystem: ${{ inputs.filesystem }}
tor: ${{ inputs.tor }}
blacklist: ${{ inputs.priority }}
test-deploy-single-node:
name: 🔁 Regular · single-node
test-deploy-chunk-1:
name: 🧱 Chunk 1
if: >-
${{ always()
&& needs.sequencing.outputs.regular == 'parallel'
&& (contains(inputs.modes, 'compose') || contains(inputs.modes, 'host'))
&& inputs.resume_from_chunk <= 1
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.test-dns.result == 'success'
&& needs.build-ci-images.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-single-node-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-host-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm-priority.result) }}
&& needs.test-dns.result == 'success'
&& (!inputs.chunk_gate
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-0.result)) }}
needs:
- test-dns
- images-mirror-missing
- sequencing
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- lint
- test
uses: ./.github/workflows/call-test-deploy-single-node.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.whitelist }}
lifecycles: ${{ inputs.lifecycles }}
filesystem: ${{ inputs.filesystem }}
modes: ${{ inputs.modes }}
tor: ${{ inputs.tor }}
blacklist: ${{ inputs.priority }}
test-deploy-compose:
name: 🔁 Regular · compose
if: >-
${{ always()
&& needs.sequencing.outputs.regular == 'serial'
&& contains(inputs.modes, 'compose')
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.test-dns.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-single-node-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-host-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm-priority.result)
&& (!inputs.mode_fail_fast
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm.result)) }}
needs:
- test-dns
- build-ci-images
- images-mirror-missing
- sequencing
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- test-deploy-swarm
- lint
- test
uses: ./.github/workflows/call-test-deploy-compose.yml
- test-dns
- test-deploy-chunk-0
uses: ./.github/workflows/call-test-deploy.yml
with:
distros: ${{ inputs.distros }}
index: "1"
sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }}
whitelist: ${{ inputs.whitelist }}
priority: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
modes: ${{ inputs.mode }}
filesystem: ${{ inputs.filesystem }}
tor: ${{ inputs.tor }}
blacklist: ${{ inputs.priority }}
test-deploy-host:
name: 🔁 Regular · host
test-deploy-chunk-2:
name: 🧱 Chunk 2
if: >-
${{ always()
&& needs.sequencing.outputs.regular == 'serial'
&& contains(inputs.modes, 'host')
&& inputs.resume_from_chunk <= 2
&& needs.lint.result == 'success'
&& needs.test.result == 'success'
&& needs.test-dns.result == 'success'
&& needs.build-ci-images.result == 'success'
&& needs.images-mirror-missing.result == 'success'
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-single-node-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-host-priority.result)
&& contains(fromJSON('["success","skipped"]'), needs.test-deploy-swarm-priority.result)
&& (!inputs.mode_fail_fast
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-compose.result)) }}
&& needs.test-dns.result == 'success'
&& (!inputs.chunk_gate
|| contains(fromJSON('["success","skipped"]'), needs.test-deploy-chunk-1.result)) }}
needs:
- test-dns
- images-mirror-missing
- sequencing
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- test-deploy-compose
- lint
- test
uses: ./.github/workflows/call-test-deploy-host.yml
- build-ci-images
- images-mirror-missing
- test-dns
- test-deploy-chunk-1
uses: ./.github/workflows/call-test-deploy.yml
with:
distros: ${{ inputs.distros }}
index: "2"
sweep: ${{ inputs.sweep != '' && inputs.sweep || github.run_number }}
whitelist: ${{ inputs.whitelist }}
priority: ${{ inputs.priority }}
lifecycles: ${{ inputs.lifecycles }}
modes: ${{ inputs.mode }}
filesystem: ${{ inputs.filesystem }}
blacklist: ${{ inputs.priority }}
exclude_compose_covered: ${{ contains(inputs.modes, 'compose') && 'true' || 'false' }}
tor: ${{ inputs.tor }}
test-runner-smoke:
needs:
- test-deploy-swarm
if: ${{ always() && vars.CI_SELF_HOSTED_RUNNER_COUNT != '0' && vars.CI_SELF_HOSTED_RUNNER_COUNT != '' && (needs.test-deploy-swarm.result == 'success' || needs.test-deploy-swarm.result == 'failure') }}
- test-deploy-chunk-0
if: ${{ always() && vars.CI_SELF_HOSTED_RUNNER_COUNT != '0' && vars.CI_SELF_HOSTED_RUNNER_COUNT != '' && (needs.test-deploy-chunk-0.result == 'success' || needs.test-deploy-chunk-0.result == 'failure') }}
uses: ./.github/workflows/call-test-runner-smoke.yml
with:
ref: ${{ github.sha }}
@@ -527,14 +328,9 @@ jobs:
report-main-failures:
name: 🐞 Report main role failures
needs:
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- test-deploy-single-node
- test-deploy-compose
- test-deploy-host
- test-deploy-swarm
- test-deploy-chunk-0
- test-deploy-chunk-1
- test-deploy-chunk-2
if: ${{ always() && github.ref == 'refs/heads/main' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
runs-on: ubuntu-latest
permissions:
@@ -558,15 +354,9 @@ jobs:
- test-dns
- test-install-make
- test-install-pkgmgr
- sequencing
- test-deploy-single-node-priority
- test-deploy-compose-priority
- test-deploy-host-priority
- test-deploy-swarm-priority
- test-deploy-single-node
- test-deploy-compose
- test-deploy-host
- test-deploy-swarm
- test-deploy-chunk-0
- test-deploy-chunk-1
- test-deploy-chunk-2
- test-runner-smoke
- test-workspace
- test-instructions
@@ -1,260 +0,0 @@
name: "📦 Test: Deploy (compose)"
on:
workflow_call:
inputs:
distros:
description: "Space-separated distro list"
required: true
type: string
whitelist:
description: "Optional space-separated whitelist of app ids"
required: false
type: string
default: ""
lifecycles:
description: "Lifecycle envelope for discovery (comma/space-separated)."
required: false
type: string
default: ""
blacklist:
description: "Optional space-separated app ids to drop from discovery"
required: false
type: string
default: ""
marker:
description: "Optional suffix appended to the discover job name (e.g. ' ⭐' for the priority line)"
required: false
type: string
default: ""
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
type: string
default: ""
tor:
description: "🧅 Tor axis: auto | enforced | exclusive | disabled."
required: false
type: string
default: auto
permissions:
contents: read
packages: read
env:
INFINITO_DISTROS: ${{ inputs.distros }}
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
INFINITO_IMAGE_TAG: ci-${{ github.sha }}
INFINITO_GHCR_MIRROR_PREFIX: mirror
INFINITO_PLAYWRIGHT_KEEP: ${{ vars.INFINITO_PLAYWRIGHT_KEEP }}
INFINITO_APP_DISCOVERY_RUNNER: host
INFINITO_DEPLOY_MODE: compose
INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }}
INFINITO_LIFECYCLES: ${{ inputs.lifecycles }}
INFINITO_BLACKLIST: ${{ inputs.blacklist }}
jobs:
discover:
name: 🪼 Compose (${{ inputs.distros }})${{ inputs.marker }}
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
apps: ${{ steps.discover.outputs.apps }}
steps:
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
- name: 🔍 Resolve repository owner
shell: bash
run: |
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: 📦 Install deps (jq + envsubst)
shell: bash
run: ./scripts/install/apt.sh jq gettext-base
- name: 🔧 Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: 📦 Install project (host venv)
shell: bash
run: make install
- name: 🔍 Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve/effective_whitelist.sh
- name: 🔍 Discover apps
id: discover
shell: bash
env:
INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
INFINITO_TOR: ${{ inputs.tor }}
RUN_NUMBER: ${{ github.run_number }}
run: ./scripts/github/resolve/output_apps.sh
- name: 🗺️ Render the plan into the run summary
shell: bash
run: |
source scripts/meta/env/load.sh
"${PYTHON}" -m cli.meta.ci.plan \
--distros "${{ inputs.distros }}" \
--whitelist "${{ steps.resolve_wl.outputs.whitelist }}" \
--priority "${{ inputs.blacklist }}" \
--modes "compose" \
--lifecycles "${{ inputs.lifecycles }}" \
>> "$GITHUB_STEP_SUMMARY"
compose:
name: 🐳${{ matrix.label }}
if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]'
runs-on: ubuntu-latest
timeout-minutes: 355
needs: discover
concurrency:
group: "infinito-deploy-compose-${{ matrix.apps }}#${{ matrix.variant_slug }}"
cancel-in-progress: false
queue: max
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.discover.outputs.apps) }}
steps:
- name: 🧹 Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v7.0.0
- name: "📥 Load rescue-diagnostics SPOT into job env"
shell: bash
run: grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env >> "$GITHUB_ENV"
- name: 💾 Enlarge swap space
shell: bash
run: ./scripts/github/runner/enlarge_swap.sh
- name: 📦 Relocate Docker data-root onto /mnt when roomier
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh
- name: 🔍 Resolve repository owner
shell: bash
run: |
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: 🚀 Deploy app
timeout-minutes: 350
shell: bash
env:
apps: ${{ matrix.apps }}
variant: ${{ matrix.variant }}
disable: ${{ matrix.disable }}
ANSIBLE_LOG_PATH: /tmp/ansible-${{ matrix.apps }}.log
run: ./scripts/tests/deploy/ci/all.sh 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log"
- name: 🚫 Fail on deprecation warnings
if: always()
shell: bash
run: ./scripts/tests/deploy/ci/assert_no_deprecation_warnings.sh "/tmp/ansible-${{ matrix.apps }}.log"
- name: 📊 Role runtimes
if: always()
shell: bash
run: |
set -euo pipefail
log="/tmp/ansible-${{ matrix.apps }}.log"
if [ ! -f "${log}" ]; then
echo "No ansible log at ${log}; deploy never started."
exit 0
fi
python3 -m cli.meta.ci.roles.runtime "${log}" \
--format csv --output "/tmp/role-runtimes-${{ matrix.apps }}.csv"
python3 -m cli.meta.ci.roles.runtime "/tmp/role-runtimes-${{ matrix.apps }}.csv" \
--format markdown >> "$GITHUB_STEP_SUMMARY"
- name: ⬆️ Upload role runtimes CSV
if: always()
uses: actions/upload-artifact@v7
with:
name: role-runtimes-${{ matrix.apps }}
path: /tmp/role-runtimes-${{ matrix.apps }}.csv
if-no-files-found: ignore
- name: 🩺 Dump runner state (on failure)
if: failure() || cancelled()
timeout-minutes: 5
shell: bash
run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})"
- name: ⬆️ Upload rescue diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: rescue-diagnostics-compose-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
path: ${{ env.INFINITO_RESCUE_DIAGNOSTICS_BASE }}/
if-no-files-found: ignore
- name: 🗜️ Zip generated inventory for debugging
if: always()
shell: bash
env:
APP_ID: ${{ matrix.apps }}
run: bash scripts/github/inventory/zip/compose_for_upload.sh
- name: ⬆️ Upload inventory artifact
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
with:
name: inventory-compose-${{ matrix.apps }}
path: /tmp/inventory-compose-${{ matrix.apps }}.zip
if-no-files-found: ignore
- name: 📋 Playwright step summary
if: always()
shell: bash
run: |
set -euo pipefail
python3 utils/github/playwright_summary.py \
/tmp/playwright-artifacts \
"${{ matrix.apps }}" \
>> "$GITHUB_STEP_SUMMARY"
- name: ⬆️ Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v7.0.1
with:
name: playwright-compose-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
path: /tmp/playwright-artifacts/
if-no-files-found: ignore
-240
View File
@@ -1,240 +0,0 @@
name: "💻 Test: Deploy (host)"
on:
workflow_call:
inputs:
distros:
description: "Space-separated distro list"
required: true
type: string
whitelist:
description: "Optional space-separated whitelist of app ids"
required: false
type: string
default: ""
lifecycles:
description: "Lifecycle envelope for discovery (comma/space-separated)."
required: false
type: string
default: ""
blacklist:
description: "Optional space-separated app ids to drop from discovery"
required: false
type: string
default: ""
marker:
description: "Optional suffix appended to the discover job name (e.g. ' ⭐' for the priority line)"
required: false
type: string
default: ""
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
type: string
default: ""
exclude_compose_covered:
description: "Drop roles the same run's compose line already deploys"
required: false
type: string
default: "false"
permissions:
contents: read
packages: read
env:
INFINITO_DISTROS: ${{ inputs.distros }}
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
INFINITO_IMAGE_TAG: ci-${{ github.sha }}
INFINITO_GHCR_MIRROR_PREFIX: mirror
INFINITO_PLAYWRIGHT_KEEP: ${{ vars.INFINITO_PLAYWRIGHT_KEEP }}
INFINITO_APP_DISCOVERY_RUNNER: host
INFINITO_DEPLOY_MODE: host
INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }}
INFINITO_LIFECYCLES: ${{ inputs.lifecycles }}
INFINITO_BLACKLIST: ${{ inputs.blacklist }}
INFINITO_HOST_EXCLUDE_COMPOSE: ${{ inputs.exclude_compose_covered }}
jobs:
discover:
name: 💻 Host (${{ inputs.distros }})${{ inputs.marker }}
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
apps: ${{ steps.discover.outputs.apps }}
steps:
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
- name: 🔍 Resolve repository owner
shell: bash
run: |
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: 📦 Install deps (jq + envsubst)
shell: bash
run: ./scripts/install/apt.sh jq gettext-base
- name: 🔧 Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: 📦 Install project (host venv)
shell: bash
run: make install
- name: 🔍 Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve/effective_whitelist.sh
- name: 🔍 Discover host roles
id: discover
shell: bash
env:
INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
run: ./scripts/github/resolve/output_apps.sh
- name: 🗺️ Render the plan into the run summary
shell: bash
run: |
source scripts/meta/env/load.sh
"${PYTHON}" -m cli.meta.ci.plan \
--distros "${{ inputs.distros }}" \
--whitelist "${{ steps.resolve_wl.outputs.whitelist }}" \
--priority "${{ inputs.blacklist }}" \
--modes "host" \
--lifecycles "${{ inputs.lifecycles }}" \
>> "$GITHUB_STEP_SUMMARY"
host:
name: 💻${{ matrix.label }}
if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]'
runs-on: ubuntu-latest
timeout-minutes: 355
needs: discover
concurrency:
group: "infinito-deploy-host-${{ matrix.apps }}#${{ matrix.variant_slug }}"
cancel-in-progress: false
queue: max
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.discover.outputs.apps) }}
steps:
- name: 🧹 Free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/checkout@v7.0.0
- name: "📥 Load rescue-diagnostics SPOT into job env"
shell: bash
run: grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env >> "$GITHUB_ENV"
- name: 💾 Enlarge swap space
shell: bash
run: ./scripts/github/runner/enlarge_swap.sh
- name: 📦 Relocate Docker data-root onto /mnt when roomier
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh
- name: 🔍 Resolve repository owner
shell: bash
run: |
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: 🚀 Deploy host role
timeout-minutes: 350
shell: bash
env:
apps: ${{ matrix.apps }}
variant: ${{ matrix.variant }}
ANSIBLE_LOG_PATH: /tmp/ansible-${{ matrix.apps }}.log
run: ./scripts/tests/deploy/ci/all.sh 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log"
- name: 🚫 Fail on deprecation warnings
if: always()
shell: bash
run: ./scripts/tests/deploy/ci/assert_no_deprecation_warnings.sh "/tmp/ansible-${{ matrix.apps }}.log"
- name: 📊 Role runtimes
if: always()
shell: bash
run: |
set -euo pipefail
log="/tmp/ansible-${{ matrix.apps }}.log"
if [ ! -f "${log}" ]; then
echo "No ansible log at ${log}; deploy never started."
exit 0
fi
python3 -m cli.meta.ci.roles.runtime "${log}" \
--format csv --output "/tmp/role-runtimes-${{ matrix.apps }}.csv"
python3 -m cli.meta.ci.roles.runtime "/tmp/role-runtimes-${{ matrix.apps }}.csv" \
--format markdown >> "$GITHUB_STEP_SUMMARY"
- name: ⬆️ Upload role runtimes CSV
if: always()
uses: actions/upload-artifact@v7
with:
name: role-runtimes-host-${{ matrix.apps }}
path: /tmp/role-runtimes-${{ matrix.apps }}.csv
if-no-files-found: ignore
- name: 🩺 Dump runner state (on failure)
if: failure() || cancelled()
timeout-minutes: 5
shell: bash
run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})"
- name: ⬆️ Upload rescue diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: rescue-diagnostics-host-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
path: ${{ env.INFINITO_RESCUE_DIAGNOSTICS_BASE }}/
if-no-files-found: ignore
- name: 🗜️ Zip generated inventory for debugging
if: always()
shell: bash
env:
APP_ID: ${{ matrix.apps }}
run: bash scripts/github/inventory/zip/compose_for_upload.sh
- name: ⬆️ Upload inventory artifact
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
with:
name: inventory-host-${{ matrix.apps }}
path: /tmp/inventory-compose-${{ matrix.apps }}.zip
if-no-files-found: ignore
@@ -1,75 +0,0 @@
name: "🏝️ Test: Deploy (single-node)"
on:
workflow_call:
inputs:
distros:
description: "Space-separated distro list"
required: true
type: string
whitelist:
description: "Optional space-separated whitelist of app ids"
required: false
type: string
default: ""
lifecycles:
description: "Lifecycle envelope for discovery (comma/space-separated)."
required: false
type: string
default: ""
modes:
description: "Single-node deploy modes to run (space-separated subset of 'compose host')."
required: false
type: string
default: "compose host"
blacklist:
description: "Optional space-separated app ids to drop from discovery"
required: false
type: string
default: ""
marker:
description: "Optional suffix appended to the discover job names (e.g. ' ⭐' for the priority line)"
required: false
type: string
default: ""
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
type: string
default: ""
tor:
description: "🧅 Tor axis for the compose leg: auto | enforced | exclusive | disabled. The host leg has no onion."
required: false
type: string
default: auto
permissions:
contents: read
packages: read
jobs:
compose:
name: 📦 Compose
if: ${{ contains(inputs.modes, 'compose') }}
uses: ./.github/workflows/call-test-deploy-compose.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.whitelist }}
lifecycles: ${{ inputs.lifecycles }}
blacklist: ${{ inputs.blacklist }}
marker: ${{ inputs.marker }}
filesystem: ${{ inputs.filesystem }}
tor: ${{ inputs.tor }}
host:
name: 💻 Host
if: ${{ contains(inputs.modes, 'host') }}
uses: ./.github/workflows/call-test-deploy-host.yml
with:
distros: ${{ inputs.distros }}
whitelist: ${{ inputs.whitelist }}
lifecycles: ${{ inputs.lifecycles }}
blacklist: ${{ inputs.blacklist }}
marker: ${{ inputs.marker }}
filesystem: ${{ inputs.filesystem }}
exclude_compose_covered: ${{ contains(inputs.modes, 'compose') && 'true' || 'false' }}
@@ -1,4 +1,4 @@
name: "🐝 Test: Deploy (swarm)"
name: "🚀 Test: Deploy"
on:
workflow_call:
@@ -7,28 +7,41 @@ on:
description: "Space-separated distro list (shown in the discover job name)"
required: true
type: string
index:
description: "Chunk index this call deploys; chunks run serially, one block per index."
required: true
type: string
sweep:
description: "Sweep number driving the mode and tor rotation and the regular-line offset."
required: true
type: string
whitelist:
description: "Optional space-separated whitelist of app ids"
required: false
type: string
default: ""
priority:
description: "Optional space-separated app ids for the priority line: sorted to the head of the list, cut into chunks of their own, and deployed in every combination they can take."
required: false
type: string
default: ""
lifecycles:
description: "Lifecycle envelope for discovery (comma/space-separated)."
required: false
type: string
default: ""
blacklist:
description: "Optional space-separated app ids to drop from discovery"
modes:
description: "Deploy modes rows may be drawn from: 'auto' for every mode, or a subset of 'host compose swarm'."
required: false
type: string
default: ""
default: "auto"
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
type: string
default: ""
marker:
description: "Optional suffix appended to the discover job name (e.g. ' ⭐' for the priority line)"
description: "Optional suffix appended to the discover job name (e.g. ' ⭐' for a priority chunk)"
required: false
type: string
default: ""
@@ -39,31 +52,48 @@ on:
workflow_dispatch:
inputs:
distros:
description: "Space-separated distro list (shown in the discover job name)"
description: "🐧 Space-separated distro list"
required: true
type: string
default: "debian"
index:
description: "📦 Chunk index to deploy."
required: false
type: string
default: "0"
sweep:
description: "🔁 Sweep number driving the mode/tor rotation and the regular-line offset."
required: false
type: string
default: "0"
whitelist:
description: "Optional space-separated whitelist of app ids"
description: "🎯 Roles to deploy. Empty: diff vs origin/main. '__ALL__': every role."
required: false
type: string
default: ""
priority:
description: "⭐ Roles to deploy first, in every combination they can take."
required: false
type: string
default: ""
lifecycles:
description: "Lifecycle envelope for discovery (comma/space-separated)."
description: "♻️ Lifecycle envelope for discovery (comma/space-separated)."
required: false
type: string
default: ""
blacklist:
description: "Optional space-separated app ids to drop from discovery"
modes:
description: "🚀 Deploy modes rows may be drawn from: 'auto', or a subset of 'host compose swarm'."
required: false
type: string
default: ""
default: "auto"
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
type: string
default: ""
tor:
description: "🧅 auto onions every second tor-capable variant and flips with the run number; enforced onions every capable one; exclusive additionally drops the variants that cannot take an onion; disabled onions none."
description: "🧅 Tor axis: 'auto' rotates the onion over the capable rows, 'enforced' onions all of them, 'exclusive' drops the rows that cannot, 'disabled' onions none."
required: false
type: choice
default: auto
options: [auto, enforced, exclusive, disabled]
@@ -78,22 +108,21 @@ env:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
INFINITO_IMAGE_TAG: ci-${{ github.sha }}
INFINITO_GHCR_MIRROR_PREFIX: mirror
INFINITO_PLAYWRIGHT_KEEP: ${{ vars.INFINITO_PLAYWRIGHT_KEEP }}
INFINITO_APP_DISCOVERY_RUNNER: host
INFINITO_DEPLOY_MODE: swarm
INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }}
INFINITO_LIFECYCLES: ${{ inputs.lifecycles }}
INFINITO_BLACKLIST: ${{ inputs.blacklist }}
jobs:
discover:
name: 🚢 Swarm (${{ inputs.distros }})${{ inputs.marker }}
name: 🔍 Chunk ${{ inputs.index }} (${{ inputs.distros }})${{ inputs.marker }}
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
apps: ${{ steps.discover.outputs.apps }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
@@ -103,10 +132,6 @@ jobs:
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: 📦 Install deps (jq + envsubst)
shell: bash
run: ./scripts/install/apt.sh jq gettext-base
@@ -133,36 +158,42 @@ jobs:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve/effective_whitelist.sh
- name: 🔍 Discover apps
- name: 🔍 Discover the chunk's rows
id: discover
shell: bash
env:
INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
INFINITO_PRIORITY: ${{ inputs.priority }}
INFINITO_MODES: ${{ inputs.modes }}
INFINITO_TOR: ${{ inputs.tor }}
RUN_NUMBER: ${{ github.run_number }}
INFINITO_CI_SWEEP: ${{ inputs.sweep }}
INFINITO_CI_CHUNK: ${{ inputs.index }}
run: ./scripts/github/resolve/output_apps.sh
- name: 🗺️ Render the plan into the run summary
shell: bash
env:
INFINITO_CI_SWEEP: ${{ inputs.sweep }}
run: |
source scripts/meta/env/load.sh
"${PYTHON}" -m cli.meta.ci.plan \
--distros "${{ inputs.distros }}" \
--whitelist "${{ steps.resolve_wl.outputs.whitelist }}" \
--priority "${{ inputs.blacklist }}" \
--modes "swarm" \
--priority "${{ inputs.priority }}" \
--modes "${{ inputs.modes }}" \
--lifecycles "${{ inputs.lifecycles }}" \
--chunk "${{ inputs.index }}" \
>> "$GITHUB_STEP_SUMMARY"
swarm:
name: "🐝${{ matrix.label }}"
deploy:
name: "${{ matrix.label }}"
if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]'
runs-on: ubuntu-latest
timeout-minutes: 355
needs: discover
concurrency:
group: "infinito-deploy-swarm-${{ matrix.apps }}#${{ matrix.variant_slug }}"
group: "infinito-deploy-${{ matrix.mode }}-${{ matrix.tor }}-${{ matrix.apps }}#${{ matrix.variant }}"
cancel-in-progress: false
queue: max
@@ -173,6 +204,7 @@ jobs:
env:
APP_ID: ${{ matrix.apps }}
INFINITO_DEPLOY_MODE: ${{ matrix.mode }}
RESCHED_TIMEOUT: "120"
SCRIPTS: scripts/tests/deploy/swarm/routine
@@ -191,7 +223,7 @@ jobs:
- name: "⬇️ Checkout"
if: ${{ !env.ACT }}
uses: actions/checkout@v7
uses: actions/checkout@v7.0.0
- name: "📥 Load rescue-diagnostics SPOT into job env"
shell: bash
@@ -207,15 +239,28 @@ jobs:
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh
- name: 🔍 Resolve repository owner
shell: bash
run: |
set -euo pipefail
echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV"
- name: 📦 Install jq
shell: bash
run: ./scripts/install/apt.sh jq
- name: "🔍 Resolve SWARM_NAME (mandatory; default to the matrix app)"
if: ${{ matrix.mode == 'swarm' }}
shell: bash
run: echo "SWARM_NAME=${SWARM_NAME:-${{ matrix.apps }}}" >> "$GITHUB_ENV"
- name: "📥 Load swarm topology SPOT into job env"
if: ${{ matrix.mode == 'swarm' }}
shell: bash
run: scripts/tests/deploy/swarm/utils/topology/export.sh
- name: 🔧 Set up Python
if: ${{ matrix.mode == 'swarm' }}
uses: actions/setup-python@v6
with:
python-version: "3.12"
@@ -223,6 +268,7 @@ jobs:
cache-dependency-path: pyproject.toml
- name: 📦 Install project (host venv)
if: ${{ matrix.mode == 'swarm' }}
shell: bash
run: |
make install-force
@@ -231,14 +277,31 @@ jobs:
echo "${VENV}/bin" >> "${GITHUB_PATH}"
- name: "🚀 Drill ${{ matrix.apps }} per distro: ${{ inputs.distros }}"
if: ${{ matrix.mode == 'swarm' }}
shell: bash
env:
variant: ${{ matrix.variant }}
disable: ${{ matrix.disable }}
run: ./scripts/tests/deploy/distros.sh "${{ env.SCRIPTS }}/00_one.sh" 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log"
- name: 🚀 Deploy app
if: ${{ matrix.mode != 'swarm' }}
timeout-minutes: 350
shell: bash
env:
apps: ${{ matrix.apps }}
variant: ${{ matrix.variant }}
disable: ${{ matrix.disable }}
ANSIBLE_LOG_PATH: /tmp/ansible-${{ matrix.apps }}.log
run: ./scripts/tests/deploy/ci/all.sh 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log"
- name: 🚫 Fail on deprecation warnings
if: ${{ always() && matrix.mode != 'swarm' }}
shell: bash
run: ./scripts/tests/deploy/ci/assert_no_deprecation_warnings.sh "/tmp/ansible-${{ matrix.apps }}.log"
- name: "🗄️ Effective node filesystems"
if: always()
if: ${{ always() && matrix.mode == 'swarm' }}
shell: bash
run: ./scripts/tests/deploy/utils/filesystem/report.sh "/tmp/ansible-${{ matrix.apps }}.log"
@@ -261,15 +324,21 @@ jobs:
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
with:
name: role-runtimes-swarm-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
name: role-runtimes-${{ matrix.artifact }}
path: /tmp/role-runtimes-${{ matrix.apps }}.csv
if-no-files-found: ignore
- name: 🩺 Dump runner state (on failure)
if: ${{ failure() && matrix.mode != 'swarm' }}
timeout-minutes: 5
shell: bash
run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})"
- name: "⬆️ Upload rescue diagnostics"
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
with:
name: rescue-diagnostics-swarm-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
name: rescue-diagnostics-${{ matrix.artifact }}
path: ${{ env.INFINITO_RESCUE_DIAGNOSTICS_BASE }}/
if-no-files-found: ignore
@@ -278,14 +347,19 @@ jobs:
shell: bash
env:
APP_ID: ${{ matrix.apps }}
run: bash scripts/github/inventory/zip/swarm_for_upload.sh
run: |
set -euo pipefail
kind=swarm
[ "${{ matrix.mode }}" = "swarm" ] || kind=compose
bash "scripts/github/inventory/zip/${kind}_for_upload.sh"
echo "INVENTORY_ZIP=/tmp/inventory-${kind}-${APP_ID}.zip" >> "$GITHUB_ENV"
- name: "⬆️ Upload inventory artifact"
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
with:
name: inventory-swarm-${{ matrix.apps }}
path: /tmp/inventory-swarm-${{ matrix.apps }}.zip
name: inventory-${{ matrix.artifact }}
path: ${{ env.INVENTORY_ZIP }}
if-no-files-found: ignore
- name: "📋 Playwright step summary"
@@ -300,13 +374,13 @@ jobs:
- name: "⬆️ Upload Playwright artifacts"
if: ${{ always() && !env.ACT }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.1
with:
name: playwright-swarm-${{ matrix.apps }}${{ matrix.variant_slug && format('-{0}', matrix.variant_slug) || '' }}
name: playwright-${{ matrix.artifact }}
path: /tmp/playwright-artifacts/
if-no-files-found: ignore
- name: "🧹 Cleanup"
if: always()
if: ${{ always() && matrix.mode == 'swarm' }}
timeout-minutes: 15
run: scripts/tests/deploy/swarm/utils/clean/teardown.sh
+26 -19
View File
@@ -1,5 +1,5 @@
name: "🕹️ Manual"
run-name: "🕹️ ${{ inputs.mode_fail_fast && '🛑 ' || '' }}${{ inputs.sequencing != 'auto' && format('🐛{0} ', inputs.sequencing == 'serial' && '序' || '并') || '' }}${{ inputs.workspace != 'auto' && format('🧑‍💻{0} ', inputs.workspace == 'true' && '是' || '否') || '' }}${{ inputs.instructions != 'auto' && format('📖{0} ', inputs.instructions == 'true' && '是' || '否') || '' }}${{ inputs.tor != 'auto' && format('{0} ', inputs.tor == 'disabled' && '🌐' || inputs.tor == 'enforced' && '🧅强制' || '🧅独占') || '' }}${{ inputs.filesystem != '' && format('🗄️{0} ', inputs.filesystem) || '' }}${{ inputs.distros != '' && format('🐧{0} ', inputs.distros) || '' }}${{ inputs.lifecycles != '' && format('♻️{0} ', inputs.lifecycles) || '' }}${{ inputs.modes != 'swarm compose host' && format('🚀{0} ', inputs.modes) || '' }}${{ inputs.priority != '' && format('⭐{0} ', inputs.priority) || '' }}${{ inputs.whitelist != '' && format('🎯{0}', inputs.whitelist) || '🔀 diff (origin/main)' }}"
run-name: "🕹️ ${{ inputs.chunk_gate && '🛑 ' || '' }}${{ inputs.resume_from_chunk != 0 && format('⏭️{0} ', inputs.resume_from_chunk) || '' }}${{ inputs.workspace != 'auto' && format('🧑‍💻{0} ', inputs.workspace == 'true' && '是' || '否') || '' }}${{ inputs.instructions != 'auto' && format('📖{0} ', inputs.instructions == 'true' && '是' || '否') || '' }}${{ inputs.tor != 'auto' && format('{0} ', inputs.tor == 'disabled' && '🌐' || inputs.tor == 'enforced' && '🧅强制' || '🧅独占') || '' }}${{ inputs.filesystem != '' && format('🗄️{0} ', inputs.filesystem) || '' }}${{ inputs.distros != '' && format('🐧{0} ', inputs.distros) || '' }}${{ inputs.lifecycles != '' && format('♻️{0} ', inputs.lifecycles) || '' }}${{ inputs.mode != 'auto' && format('🚀{0} ', inputs.mode) || '' }}${{ inputs.priority != '' && format('⭐{0} ', inputs.priority) || '' }}${{ inputs.whitelist != '' && format('🎯{0}', inputs.whitelist) || '🔀 diff (origin/main)' }}"
concurrency:
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
@@ -14,7 +14,7 @@ on:
default: ""
type: string
priority:
description: "⭐ Roles to deploy first. The rest waits until they are green."
description: "⭐ Roles to deploy first, in every combination they can take (each variant × each offered mode × Tor and clearnet). They get chunks of their own; the regular rows wait until those are green."
required: false
default: ""
type: string
@@ -28,11 +28,16 @@ on:
required: false
default: ""
type: string
modes:
description: "🚀 Deploy modes to test. Only the listed ones run their deploy job."
mode:
description: "🚀 Deploy mode. 'auto' rotates every row over the modes its role offers; a single mode keeps only the rows that mode can take."
required: false
default: "swarm compose host"
type: string
default: "auto"
type: choice
options:
- "auto"
- "host"
- "compose"
- "swarm"
filesystem:
description: "🗄️ Space-separated filesystems the per-distro pick draws from (ext4, btrfs, zfs). Empty: all three."
required: false
@@ -48,20 +53,21 @@ on:
- "enforced"
- "exclusive"
- "disabled"
sequencing:
description: "🐛 Deploy-mode order per line. 'serial': swarm, then compose, then host. 'parallel': all at once. 'auto': serial above 65 jobs per line."
resume_from_chunk:
description: "⏭️ Skip every chunk below this index. Use to continue a sweep after fixing what broke it, instead of re-running the green chunks."
required: false
default: "auto"
type: choice
options:
- "auto"
- "serial"
- "parallel"
mode_fail_fast:
description: "🛑 Stop a serial line at its first failed mode. Off: keep deploying the remaining modes."
default: 0
type: number
chunk_gate:
description: "🛑 Stop the chunk chain at its first failed chunk. Off: keep deploying the remaining chunks."
required: false
default: true
type: boolean
sweep:
description: "🔁 Sweep number driving the mode/tor rotation and the regular-line offset. Empty: the run number."
required: false
default: ""
type: string
workspace:
description: "🧑‍💻 Run the workspace test (dev-runtime image matrix). 'auto' runs it on a global scope only."
required: false
@@ -122,10 +128,11 @@ jobs:
distros: ${{ needs.pick-distro.outputs.distros }}
whitelist: ${{ inputs.whitelist }}
lifecycles: ${{ inputs.lifecycles }}
modes: ${{ inputs.modes }}
mode: ${{ inputs.mode }}
priority: ${{ inputs.priority }}
sequencing: ${{ inputs.sequencing }}
mode_fail_fast: ${{ inputs.mode_fail_fast }}
sweep: ${{ inputs.sweep }}
resume_from_chunk: ${{ inputs.resume_from_chunk }}
chunk_gate: ${{ inputs.chunk_gate }}
filesystem: ${{ inputs.filesystem }}
workspace: ${{ inputs.workspace }}
workspace_track: ${{ inputs.workspace_track }}
+4 -4
View File
@@ -713,10 +713,10 @@ swarm-zombie: install-act
SWARM_NAME=$(or $(name),$(app)); \
INFINITO_SWARM_STEP_TIMEOUT_MINUTES=$(or $(step_timeout),690); \
INFINITO_DISTROS=$(SWARM_DISTROS)" \
ACT_WORKFLOW=.github/workflows/call-test-deploy-swarm.yml \
ACT_JOB=swarm \
ACT_MATRIX='apps:$(app);variant:$(or $(variant),0)' \
ACT_INPUTS="whitelist=$(app) distros=$(SWARM_DISTROS)" \
ACT_WORKFLOW=.github/workflows/call-test-deploy.yml \
ACT_JOB=deploy \
ACT_MATRIX='apps:$(app);variant:$(or $(variant),0);mode:swarm' \
ACT_INPUTS="whitelist=$(app) distros=$(SWARM_DISTROS) index=0 sweep=0" \
bash scripts/tests/deploy/act/workflow.sh
.PHONY: system-purge
+5 -9
View File
@@ -1,21 +1,17 @@
#!/usr/bin/env bash
#
# Resolve the app list and write it to GITHUB_OUTPUT.
# Resolve the chunk's deploy matrix and write it to GITHUB_OUTPUT.
# Inputs via env (forwarded to scripts/meta/resolve/apps.sh):
# INFINITO_WHITELIST — optional space-separated allowlist
# INFINITO_CI_CHUNK — chunk index to emit
# INFINITO_CI_SWEEP — sweep number
# INFINITO_WHITELIST / INFINITO_PRIORITY / INFINITO_MODES / INFINITO_TOR
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
cd "${REPO_ROOT}"
# shellcheck source=scripts/meta/env/python.sh
source "${REPO_ROOT}/scripts/meta/env/python.sh"
apps="$(./scripts/meta/resolve/apps.sh)"
[[ -n "$apps" ]] || apps='[]'
matrix="$(printf '%s' "$apps" | "${PYTHON}" -m utils.github.variant.bundles)"
matrix="$(./scripts/meta/resolve/apps.sh)"
[[ -n "$matrix" ]] || matrix='[]'
echo "apps=$matrix" >>"$GITHUB_OUTPUT"
+9 -1
View File
@@ -12,15 +12,23 @@
# Inputs (environment variables):
# APPS_JSON JSON array of app IDs from the discover step
# CI_SELF_HOSTED_RUNNER_COUNT self-hosted runner instance count (default: 0)
# INFINITO_CI_CONCURRENCY GitHub-hosted runner slots (SPOT: default.env)
#
# Outputs (appended to $GITHUB_OUTPUT):
# apps_github JSON array routed to GitHub-hosted runners
# apps_self_hosted JSON array routed to self-hosted runners ([] when disabled)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
if [[ -f "${REPO_ROOT}/scripts/meta/env/load.sh" ]]; then
# shellcheck source=scripts/meta/env/load.sh
source "${REPO_ROOT}/scripts/meta/env/load.sh"
fi
APPS_JSON="${APPS_JSON:-[]}"
SELF_HOSTED_COUNT="${CI_SELF_HOSTED_RUNNER_COUNT:-0}"
GITHUB_QUOTA=20
GITHUB_QUOTA="${INFINITO_CI_CONCURRENCY:?INFINITO_CI_CONCURRENCY must be set (SPOT: default.env)}"
if [[ "${SELF_HOSTED_COUNT}" -le 0 ]]; then
echo "apps_github=${APPS_JSON}" >>"${GITHUB_OUTPUT}"
+27 -28
View File
@@ -1,24 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
# Resolve the JSON list the CI matrix deploys for the current
# INFINITO_DEPLOY_MODE. The query itself (filter, coverage-first sort,
# lifecycle envelope, INFINITO_MAX_JOBS cap with 'auto') lives in
# cli.meta.ci.query, shared with the deploy-plan table. Compose and host
# emit whole role names; swarm emits per-variant "role#variant" tokens.
# variant.bundles maps the list onto matrix entries.
# Resolve the CI deploy matrix for one chunk of one sweep. The pipeline itself
# (two discovery queries, the mode/tor rotation, the chunk split) lives in
# cli.meta.ci.matrix, shared with the deploy-plan table, so the matrix this
# emits and the plan the summary renders can never disagree. Every entry is one
# role#variant row carrying the deploy mode it was assigned.
#
# Inputs via env (defaults live in default.env, the single source of truth):
# INFINITO_DEPLOY_MODE compose|swarm|host (required; workflows set it)
# INFINITO_CI_CHUNK chunk index to emit (required)
# INFINITO_CI_SWEEP sweep number driving rotation and offset
# INFINITO_MODES 'auto' or a subset of 'host compose swarm'
# INFINITO_WHITELIST optional space-separated app ids to keep
# INFINITO_BLACKLIST optional space-separated app ids to drop
# INFINITO_MAX_JOBS cumulative job cap; 'auto' derives it per
# mode from the CI chain via cli.meta.ci.slots
# INFINITO_PRIORITY optional space-separated app ids to lead
# INFINITO_TOR auto|enforced|exclusive|disabled
# INFINITO_LIFECYCLES lifecycle envelope for discovery
# INFINITO_DISCOVERY_SORT complexity --sort spec (coverage-first)
# INFINITO_REQUIRED_STORAGE per-runner CI storage budget
# INFINITO_APP_DISCOVERY_RUNNER host|docker
#
# Output: JSON array to stdout (single line, always valid).
# Output: JSON array of matrix entries to stdout (single line, always valid).
PYTHON="${PYTHON:-python3}"
@@ -52,23 +53,21 @@ run_meta_cli() {
esac
}
mode="${INFINITO_DEPLOY_MODE:?INFINITO_DEPLOY_MODE must be set to compose, swarm or host}"
case "$mode" in
compose | swarm | host) ;;
*)
echo "apps.sh: INFINITO_DEPLOY_MODE must be compose, swarm or host, got '$mode'" >&2
exit 2
;;
esac
apps_json="$(run_meta_cli -m cli.meta.ci.query --mode "$mode" --format json)"
apps_json="$(printf '%s' "${apps_json}" | jq -c 'sort')"
matrix_json="$(
run_meta_cli -m cli.meta.ci.matrix \
--index "${INFINITO_CI_CHUNK:?INFINITO_CI_CHUNK must be set to the chunk index to emit}" \
--sweep "${INFINITO_CI_SWEEP}" \
--modes "${INFINITO_MODES}" \
--whitelist "${INFINITO_WHITELIST}" \
--priority "${INFINITO_PRIORITY}" \
--lifecycles "${INFINITO_LIFECYCLES}" \
--tor "${INFINITO_TOR}"
)"
if [[ -n "${GITHUB_ACTIONS:-}" && -z "${ACT:-}" ]]; then
required_storage="${INFINITO_REQUIRED_STORAGE}"
mapfile -t roles < <(printf '%s\n' "${apps_json}" | jq -r '.[] | split("#")[0]' | sort -u)
mapfile -t roles < <(printf '%s\n' "${matrix_json}" | jq -r '.[].apps' | sort -u)
if [[ "${#roles[@]}" -gt 0 ]]; then
run_meta_cli \
-m cli.meta.roles.applications.sufficient_storage \
@@ -87,12 +86,12 @@ if [[ -n "${GITHUB_ACTIONS:-}" && -z "${ACT:-}" ]]; then
json_compact_array
)"
apps_json="$(
printf '%s' "${apps_json}" |
matrix_json="$(
printf '%s' "${matrix_json}" |
jq -c --argjson keep "${kept_roles}" \
'map(select(. as $t | $keep | index($t | split("#")[0]) != null))'
'map(select(.apps as $a | $keep | index($a) != null))'
)"
fi
fi
printf '%s\n' "${apps_json}" | json_compact_array
printf '%s\n' "${matrix_json}" | json_compact_array
+1 -1
View File
@@ -173,7 +173,7 @@ the cluster declaration (image, containers, network, DNS play) in
The matrix orchestrator
(`utils/tests/swarm/matrix.py`) drives the deploy stage
per variant round; `routine/00_one.sh` drives the surrounding stages for one
distro, and the workflow `.github/workflows/call-test-deploy-swarm.yml` drives the
distro, and the workflow `.github/workflows/call-test-deploy.yml` drives the
distro loop. Run one app locally with
`make swarm-zombie app=<id>` (keeps the cluster for inspection) or the
whole matrix via `make roundtrip`.
@@ -3,11 +3,11 @@ from __future__ import annotations
import unittest
from cli.administration.deploy.ci import runs
from tests.utils.ci.job_names import ORCHESTRATOR_PREFIX, deploy_job_name
from tests.utils.ci.job_names import deploy_job_name, orchestrator_prefix
MODES = ("docker", "swarm")
MODES = ("docker", "swarm", "host")
SAMPLE_APP = "web-app-matomo"
SAMPLE_VARIANTS = ("", "0", "0,1", "0,1,2")
SAMPLE_VARIANTS = ("", "0", "2")
class TestCiDeployJobNamesParse(unittest.TestCase):
@@ -31,7 +31,7 @@ class TestCiDeployJobNamesParse(unittest.TestCase):
def test_failed_roles_detect_each_scope(self) -> None:
jobs = [
{
"name": deploy_job_name(mode, SAMPLE_APP, "0,1"),
"name": deploy_job_name(mode, SAMPLE_APP, "1"),
"status": "completed",
"conclusion": "failure",
}
@@ -44,8 +44,8 @@ class TestCiDeployJobNamesParse(unittest.TestCase):
def test_non_deploy_jobs_are_ignored(self) -> None:
noise = [
{"name": ORCHESTRATOR_PREFIX["docker"] + "⛵ Navigate composition"},
{"name": ORCHESTRATOR_PREFIX["swarm"] + "🍯 Lure swarm"},
{"name": orchestrator_prefix(0) + "⛵ Navigate composition"},
{"name": orchestrator_prefix(1) + "🍯 Lure swarm"},
{"name": "🐳 Update Docker image versions"},
]
self.assertEqual(runs.parse_role_statuses(noise), {})
@@ -1,104 +0,0 @@
"""The orchestrator's two deploy layouts must stay mutually exclusive.
Each line renders swarm plus either the parallel single-node job or the
serial compose -> host chain; a `needs:` edge cannot be conditional, so both
layouts exist as jobs and the sequencing decision skips one. Nothing at
runtime proves the ten hand-written `if` expressions still agree with each
other, and the failure is silent in both directions: two layouts deploying
the same roles twice, or neither deploying anything.
These are structural assertions, not an expression evaluator. They catch the
mistakes this shape actually invites -- a copied sentinel, a missing chain
edge, a fail-fast guard pointing at the wrong predecessor.
"""
from __future__ import annotations
import unittest
from itertools import pairwise
from tests.utils import PROJECT_ROOT
from utils.cache.yaml import load_yaml
ORCHESTRATOR = PROJECT_ROOT / ".github" / "workflows" / "call-orchestrator.yml"
LINES = {"priority": "-priority", "regular": ""}
CHAIN = ("test-deploy-swarm", "test-deploy-compose", "test-deploy-host")
PARALLEL = "test-deploy-single-node"
def _jobs() -> dict:
return load_yaml(str(ORCHESTRATOR))["jobs"]
def _job(name: str, suffix: str) -> dict:
return _jobs()[f"{name}{suffix}"]
def _condition(name: str, suffix: str) -> str:
return " ".join(str(_job(name, suffix).get("if", "")).split())
def _needs(name: str, suffix: str) -> list[str]:
declared = _job(name, suffix).get("needs", [])
return [declared] if isinstance(declared, str) else list(declared)
class TestLayoutsAreMutuallyExclusive(unittest.TestCase):
def test_the_serial_chain_selects_on_the_serial_sentinel(self) -> None:
for line, suffix in LINES.items():
for name in CHAIN[1:]:
with self.subTest(line=line, job=name):
self.assertIn(
f"needs.sequencing.outputs.{line} == 'serial'",
_condition(name, suffix),
)
def test_the_parallel_job_selects_on_the_parallel_sentinel(self) -> None:
for line, suffix in LINES.items():
with self.subTest(line=line):
self.assertIn(
f"needs.sequencing.outputs.{line} == 'parallel'",
_condition(PARALLEL, suffix),
)
def test_a_skipped_dependency_cannot_skip_a_serial_job(self) -> None:
for line, suffix in LINES.items():
for name in CHAIN[1:]:
with self.subTest(line=line, job=name):
self.assertIn("always()", _condition(name, suffix))
class TestSerialChain(unittest.TestCase):
def test_each_mode_waits_for_its_predecessor(self) -> None:
for line, suffix in LINES.items():
for predecessor, successor in pairwise(CHAIN):
with self.subTest(line=line, job=successor):
self.assertIn(f"{predecessor}{suffix}", _needs(successor, suffix))
def test_swarm_leads_and_waits_for_no_decision(self) -> None:
"""It runs first in either layout, so making it wait for the
sequencing job would delay every run for nothing."""
for line, suffix in LINES.items():
with self.subTest(line=line):
self.assertNotIn("sequencing", _needs(CHAIN[0], suffix))
def test_fail_fast_guards_the_immediate_predecessor(self) -> None:
for line, suffix in LINES.items():
for predecessor, successor in pairwise(CHAIN):
with self.subTest(line=line, job=successor):
condition = _condition(successor, suffix)
self.assertIn("inputs.mode_fail_fast", condition)
self.assertIn(f"needs.{predecessor}{suffix}.result", condition)
class TestRegularLineWaitsForPriority(unittest.TestCase):
def test_every_priority_job_gates_every_regular_job(self) -> None:
priority = [f"{name}-priority" for name in (PARALLEL, *CHAIN)]
for name in (PARALLEL, *CHAIN):
with self.subTest(job=name):
self.assertLessEqual(set(priority), set(_needs(name, "")))
if __name__ == "__main__":
unittest.main()
@@ -25,9 +25,7 @@ from . import PROJECT_ROOT
HANDLERS = PROJECT_ROOT / "roles" / "sys-svc-compose" / "handlers"
SWARM = HANDLERS / "swarm.yml"
COMPOSE = HANDLERS / "compose.yml"
COMPOSE_WORKFLOW = (
PROJECT_ROOT / ".github" / "workflows" / "call-test-deploy-compose.yml"
)
DEPLOY_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "call-test-deploy.yml"
BUILDS = {
"swarm: pre-deploy build of local images": (SWARM, "_swarm_pre_deploy_build"),
@@ -57,13 +55,25 @@ def _fallbacks(path, var):
def _longest_step_seconds(path):
"""Longest per-step timeout the deploy workflow declares, in seconds.
Raises instead of falling back: an empty result means the workflow was
renamed or its step timeouts vanished, and a silent 0 would let any build
ladder pass the budget assertion below.
"""
document = load_yaml_any(str(path), default_if_missing={}) or {}
return 60 * max(
timeouts = [
step["timeout-minutes"]
for job in (document.get("jobs") or {}).values()
for step in (job.get("steps") or [])
if "timeout-minutes" in step
)
]
if not timeouts:
raise AssertionError(
f"{path} declares no step-level 'timeout-minutes'; the compose "
f"build-retry budget is derived from it."
)
return 60 * max(timeouts)
def _by_name(path, name):
@@ -87,7 +97,7 @@ class TestComposeBuildRetryBudget(unittest.TestCase):
"INFINITO_CI_DISTRO_BUDGET_SECONDS"
]
)
cap = _longest_step_seconds(COMPOSE_WORKFLOW)
cap = _longest_step_seconds(DEPLOY_WORKFLOW)
for name, (path, var) in BUILDS.items():
task = _by_name(path, name)
worst = (
+55 -27
View File
@@ -4,6 +4,10 @@ Single point of truth for test fixtures so they cannot drift back to job
names GitHub never emits -- the bug that silently made ``--failed swarm`` a
no-op was masked by fixtures hand-typed as ``🐳 Compose web-app-x``, which no
real job is ever called.
Since the deploy modes collapsed into one workflow, a job's mode is no longer
in the workflow file name but in the row's own label, so the glyph prefix is
built here the same way ``utils.github.variant.axes`` builds it.
"""
from __future__ import annotations
@@ -12,61 +16,85 @@ import re
from tests.utils import PROJECT_ROOT
from utils.cache.files import read_text
from utils.github.variant.axes import TOR_DEPLOY_MODES
from utils.roles.display import display_names
from utils.symbol_glossary import to_emoji
WORKFLOWS = PROJECT_ROOT / ".github" / "workflows"
_DEPLOY = {
"docker": ("call-test-deploy-compose.yml", "compose"),
"swarm": ("call-test-deploy-swarm.yml", "swarm"),
"host": ("call-test-deploy-host.yml", "host"),
}
ORCHESTRATOR_PREFIX = {
"docker": "🎶 Orchestrate CI / test-deploy-compose / ",
"swarm": "🎶 Orchestrate CI / test-deploy-swarm / ",
"host": "🎶 Orchestrate CI / test-deploy-host / ",
}
DEPLOY_WORKFLOW = "call-test-deploy.yml"
_MODE_NAMES = {"docker": "compose", "swarm": "swarm", "host": "host"}
_NAME_RE = re.compile(r"name: (.+)$", re.MULTILINE)
def _template(mode: str, job_id: str | None = None) -> str:
workflow_file, deploy_job = _DEPLOY[mode]
job_id = job_id or deploy_job
def orchestrator_prefix(chunk: int = 0) -> str:
"""The caller path GitHub prepends to a chunk's nested deploy jobs."""
return f"🎶 Orchestrate CI / test-deploy-chunk-{chunk} / "
def _template(job_id: str) -> str:
block = re.search(
rf"^ {job_id}:\n((?: .*\n)+)",
read_text(str(WORKFLOWS / workflow_file)),
read_text(str(WORKFLOWS / DEPLOY_WORKFLOW)),
re.MULTILINE,
)
assert block, f"job '{job_id}' not found in {workflow_file}"
assert block, f"job '{job_id}' not found in {DEPLOY_WORKFLOW}"
name = _NAME_RE.search(block.group(1))
assert name, f"no name: in job '{job_id}' of {workflow_file}"
assert name, f"no name: in job '{job_id}' of {DEPLOY_WORKFLOW}"
return name.group(1).strip().strip('"').strip("'")
def discover_job_name(mode: str, distros: str, marker: str = "") -> str:
"""The job display name GitHub emits for *mode*'s discover step.
def discover_job_name(chunk: int, distros: str, marker: str = "") -> str:
"""The job display name GitHub emits for a chunk's discover step.
Args:
mode: ``'docker'`` (compose), ``'swarm'`` or ``'host'``.
chunk: chunk index the block deploys.
distros: the distro list the run swept.
marker: line suffix, e.g. ``' ⭐'`` for the priority line.
marker: suffix, e.g. ``' ⭐'`` for a priority chunk.
"""
rendered = _template(mode, "discover").replace("${{ inputs.distros }}", distros)
rendered = _template("discover").replace("${{ inputs.index }}", str(chunk))
rendered = rendered.replace("${{ inputs.distros }}", distros)
return rendered.replace("${{ inputs.marker }}", marker)
def row_label(
mode: str, app: str, variant: str = "", *, tor: bool = False, priority: bool = False
) -> str:
"""The ``matrix.label`` axes assigns to one row: mode glyph, tor glyph on
the modes that carry the onion axis, display name, and the priority star."""
deploy_mode = _MODE_NAMES[mode]
glyphs = to_emoji(deploy_mode)
if deploy_mode in TOR_DEPLOY_MODES:
glyphs += to_emoji("tor" if tor else "clearnet")
label = f"{glyphs}{display_names().encode(app, variant)}"
return f"{label} {to_emoji('priority')}" if priority else label
def deploy_job_name(
mode: str, app: str, variant: str = "", *, orchestrated: bool = True
mode: str,
app: str,
variant: str = "",
*,
tor: bool = False,
priority: bool = False,
chunk: int = 0,
orchestrated: bool = True,
) -> str:
"""The job display name GitHub emits for an ``app`` deploy in ``mode``.
Args:
mode: ``'docker'`` (compose) or ``'swarm'``.
mode: ``'docker'`` (compose), ``'swarm'`` or ``'host'``.
app: role id, e.g. ``'web-app-matomo'``.
variant: shard token GitHub appends, e.g. ``'0,1'`` (``''`` = single).
variant: the row's variant index, e.g. ``'0'`` (``''`` = none).
tor: whether the row deploys behind the node onion.
priority: whether the row belongs to the priority line.
chunk: chunk index, for the orchestrator prefix.
orchestrated: include the ci-orchestrator caller prefix (real runs do).
"""
rendered = _template(mode).replace(
"${{ matrix.label }}", display_names().encode(app, variant)
rendered = _template("deploy").replace(
"${{ matrix.label }}",
row_label(mode, app, variant, tor=tor, priority=priority),
)
return (ORCHESTRATOR_PREFIX[mode] if orchestrated else "") + rendered
return (orchestrator_prefix(chunk) if orchestrated else "") + rendered