Files
computer-playbook/.github/workflows
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
..
…

Workflow Dependency Map

How the workflows under .github/workflows/ trigger and call each other. Solid arrows: workflow_call (uses:) or job needs. Dotted arrows: indirect coupling (CLI dispatch, shared concurrency group). Per-workflow inputs and descriptions: workflows.md.

CI pipeline

flowchart TB
    push["push: main, feature/**, hotfix/**, fix/**"] --> epl["entry-push-latest.yml"]
    pr["pull_request: opened, synchronize, reopened, ready_for_review"] --> eprc["entry-pr-change-orchestrate.yml"]
    dispatch["workflow_dispatch"] --> eman["entry-manual-steer.yml"]

    epl --> orch["ci-orchestrator.yml"]
    epl -->|"version tag on main"| relv["release-version.yml"]
    eprc --> orch
    eprc -->|"fork PRs: privileged prebuild"| imgbuild["images-build-ci.yml"]
    eprc -->|"fork PRs: privileged prebuild"| imgmirror["images-mirror-missing.yml"]
    eman --> orch

    subgraph orchestrator["ci-orchestrator.yml jobs"]
        waitfork["wait-fork-prereq-run"] --> forkready["fork-prereqs-ready"]

        lintwf["lint.yml: make lint + hadolint"]
        testwf["test.yml: make test"]
        codeql["cron-security-codeql.yml"]
        buildci["build-ci-images: images-build-ci.yml"] --> dns["test-dns.yml"]
        mirror["images-mirror-missing.yml"]

        subgraph chain["serial chunk chain"]
            chunk0["test-deploy-chunk-0"]
            chunk0 --> chunk1["test-deploy-chunk-1"]
            chunk1 --> chunk2["test-deploy-chunk-2"]
        end

        lintwf --> chain
        testwf --> chain
        dns --> chain
        mirror --> chain
        buildci --> chain

        chunk0 --> smoke["test-runner-smoke.yml"]
        chain --> report["report-main-failures"]

        instmake["test-install-make.yml"]
        instpkgmgr["test-install-pkgmgr.yml"]
        mirror --> devenv["test-workspace: test-workspace.yml"]
        buildci --> testguide["test-instructions.yml"]
        mirror --> testguide

        chain --> donegate["done"]
        smoke --> donegate
        instmake --> donegate
        instpkgmgr --> donegate
        devenv --> donegate
        testguide --> donegate
    end

    chunk0 --> deploy["call-test-deploy.yml"]
    chunk1 --> deploy
    chunk2 --> deploy

Sweeps and chunks

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.

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

The chunk size is what makes that safe, and cli.meta.ci.slots derives it rather than pinning it:

waves      = floor(INFINITO_CI_QUEUE_HOURS / the deploy job's timeout-minutes)
chunk size = INFINITO_CI_CONCURRENCY * waves

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.

How many chunks a sweep spends

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.

Selection tokens

An entry in whitelist or priority is a role name that MAY pin the axes the row would otherwise be assigned. Two spellings are accepted:

Form Example
ASCII web-app-nextcloud#0,2@swarm+tor
the job label, pasted back out of the failed run 🐳🧅网络应用·Nextcloud#0

# selects variants (comma-separated), @ the deploy mode, +tor / +clearnet the onion state. The onion state is spelled +clearnet rather than -tor because a role id may itself end in -tor.

What a token leaves open belongs to the line it stands in: priority covers every remaining combination in one sweep, whitelist keeps the rotation and the row's position in the matrix. A pin the row cannot take — @swarm on a role without its own stack, +tor on a variant that pins services.tor.enabled false, or an axis that fights the run's own mode / tor input — aborts the matrix rather than dropping the row, because a dropped row would report a green run for a combination that never ran.

Mode and tor

The tor input decides what the onion axis is allowed to do at all:

Value Effect
auto rotates; a priority row covers both states in one sweep
enforced every capable row runs behind the onion
exclusive as enforced, and rows that cannot take an onion are dropped
disabled no row takes the onion

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
mode (position + sweep) % len(modes the role offers)
tor (position + sweep // 2) % 2

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.

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

chunk_gate (default true) decides whether the chain stops at its first failed chunk:

Value Behaviour
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 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

flowchart TB
    prclose["pull_request_target: closed, converted_to_draft"] --> eprcancel["entry-pr-closed-cancel-workflows.yml"]
    branchdelete["delete: branch"] --> delbranch["entry-delete-branch.yml"]
    eprcancel -.->|"cancels concurrency group"| runningci["running entry + child workflow runs"]
    delbranch -.->|"cancels concurrency group"| runningci

Scheduled and standalone

flowchart TB
    daily["schedule: daily 00:00 UTC"] --> mirrorall["cron-images-mirror-all.yml"]
    daily --> stale["cron-cleanup-stale.yml"]
    daily --> relhighest["cron-release-highest.yml"]
    weekly["schedule: weekly Sat 00:00 UTC"] --> updatewf["cron-update.yml"]
    weekly --> cleanupci["cron-images-cleanup-ci.yml"]
    weeklymon["schedule: weekly Mon 00:00 UTC"] --> scorecard["cron-security-scorecard.yml"]
    branchprot["branch_protection_rule"] --> scorecard
    pushmain["push: main"] --> updatewf
    prtarget["pull_request_target: opened, reopened"] --> depclose["entry-pr-open-dependabot-close.yml"]

    relhighest -.->|"gh workflow run"| relver["release-version.yml"]
    relver --> imgbuildci["images-build-ci.yml"]
    manual["workflow_dispatch"] --> mirrorcleanup["images-mirror-cleanup.yml"]
    manual --> deploywf["test-instructions.yml: run a role README Production command"]

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-dns.yml, test-workspace.yml, test-runner-smoke.yml.