index
Shared reusable GitHub Actions workflows for tuna-os's BuildStream
desktop-image repos (currently tuna-os/tromso, tuna-os/xfce-linux) —
and open to any other BuildStream-based desktop image repo, in or out of
the tuna-os org.
Why
The multi-runner chunked-build pipeline (plan → core → parallel dep chunks,
sharing CAS state via GHCR-hosted zstd tarballs) is identical across every
desktop repo except for the image name, build target, and chunk count. It
was being hand-copied between tromso and xfce-linux, which drifts:
every CI fix had to be applied twice, and it was easy to forget one.
Usage
jobs:
multirunner:
uses: tuna-os/bst-ci/.github/workflows/multirunner-build.yml@main
with:
image_name: your-image
bst_target: oci/your-image.bst
num_chunks: '10' # optional, default 10
core_split: '200' # optional, default 200
build_final:
needs: multirunner
if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled')
runs-on: ubuntu-24.04
# ... export, sign, push — stays in your own repo. See "Scope" below.
scripts/ci-build-matrix.py (also in this repo) is what multirunner-build.yml
runs inside the pinned bst2 container to split uncached elements into a
core set + num_chunks round-robin chunks with composite cache keys. It's
a plain script with no repo-specific assumptions — multirunner-build.yml
checks this repo out into .bst-ci/ alongside the caller's own checkout
and invokes it from there, so consumers should not carry their own copy
of this script (tromso and xfce-linux both used to; both had it removed
once this workflow stopped needing it).
Making chunks stop rebuilding the same expensive elements
Chunks are round-robin slices of the dependency order, so chunk i holds elements scattered across the whole graph — including elements near the very end, whose transitive closure is almost everything. A chunk therefore builds whatever its dependencies need that is not already in the core CAS it restored, and different chunks redo the same work. On tuna-os/xfce-linux this was measured at 2.6× (487 distinct elements, 1281 element-builds in one run), and on tuna-os/tromso at 3.4×; LLVM alone was built by 4–5 chunks per run at ~2 hours each.
Two inputs address this, and neither disturbs existing GHCR chunk caches:
| Input | Default | What it does |
|---|---|---|
extra_core_targets | '' | Extra elements built in build_core in addition to the first core_split plan entries. The chunk matrix is still derived from core_split alone, so chunk names and cache keys are unchanged. Anything listed here is built once and reaches every chunk through the shared core CAS. |
soft_core_budget | false | Lets build_core exhaust its budget without failing the job. Core is a cache-warming job whose partial CAS is pushed either way, so when you deliberately give it more work than fits in one job the timeout is a checkpoint, not a fault. Only exit code 124 is softened. |
soft_chunk_budget | false | Lets dependency chunks treat exit code 124 as a cache-warming checkpoint instead of failing the run. This allows the caller's build_final job to consume the partial CAS and lets later runs resume from the rolling cache. Real build errors still fail. |
Raising num_chunks is not a substitute: with round-robin slicing it
multiplies the duplicated closure across more runners rather than dividing
the work, and because chunk names are derived from each chunk's first element
(chunk{i}-{label}), changing num_chunks or core_split renames every
chunk and discards every warm chunk cache. extra_core_targets was added
specifically so the shared spine can be moved into core without paying that.
core_budget_minutes (default '270') sets the timeout around core's
bst build. It has to stay inside the 360-minute job timeout with room for
the CAS archive+push that follows, which grows with the cache: a chunk push
of a comparable CAS has been measured at 16 minutes. The chunk budget is
deliberately left at 270 — a job-level timeout cancels the job and throws
away the whole chunk, so the remaining headroom there is not worth spending.
Enable soft_chunk_budget when chunks routinely reach that 270-minute limit
but still make useful progress. A timed-out chunk publishes only its rolling
:latest cache tag; it does not publish the exact :<cache_key> tag, because
that tag means the chunk completed. This makes repeated runs converge without
allowing a partial chunk to masquerade as complete. Leave the input disabled
when a timeout should block final assembly. soft_core_budget applies the same
exit-code-124 policy to the serial core job; the two inputs are independent.
runner_label / runner_label_aarch64 (default ubuntu-24.04 /
ubuntu-24.04-arm) set runs-on for planning, core and chunks. This is the
only lever that shortens a single expensive element: nothing about chunking
can split one bst element build across runners. A label that is not
provisioned for the calling repository queues forever, so only set it to a
label known to resolve there.
Verification without a build
Both scripts and workflows here are checked without needing BuildStream, podman, or a real chunked build to run:
tests/pytest/— unit tests for the pure functions inci-build-matrix.py, plus CLI-level tests that drive the script as a subprocess against a syntheticbuild-plan.txt(nobst showrequired). Run locally:pytest tests/pytest/ -v.actionlint+yamllinton every workflow file, including this repo's own.github/workflows/test.yml(dogfooded — this repo lints itself).- Before changing the
workflow_callinputs:/outputs:contract, grep both consumers forwith:keys andneeds.multirunner.outputs.*usages to make sure nothing here silently stops matching what they expect — GitHub doesn't validate that across repos for you. scripts/lint_bst.py— static lint for.bstelement files (see below).
scripts/lint_bst.py
Catches two classes of mistake in new/changed .bst files without
BuildStream, a junction fetch, or a real build. It requires Python 3 and
PyYAML (python3 -m pip install pyyaml):
- Structural: invalid YAML and a missing
kind:are errors. An unrecognized BuildStream plugin kind (including a possible typo such askind: meason) is reported as a non-fatal warning because the script's built-in list may not include every valid third-party plugin. - Cross-reference: every junction-qualified dependency (anything with
a
:in it, e.g.freedesktop-sdk.bst:components/foo.bst) named by a new/changed file is checked against every.bstfile already in the tree. A dependency referenced nowhere else is flagged — it may not actually exist in the junctioned project; this script only knows whether this codebase has ever successfully referenced it before, not whether it's real. It found exactly this kind of gap on first use: scaffoldingcage.bstfor tuna-os/xfce-linux#39 referencedfreedesktop-sdk.bst:components/wlroots.bst, which nothing else in eithertromsoorxfce-linuxhad ever depended on — worth a second look before that PR merges.
# Lint an entire tree (structural checks only):
python3 scripts/lint_bst.py path/to/elements
# Also flag unconfirmed dependencies introduced by specific new/changed files:
python3 scripts/lint_bst.py path/to/elements --check-new path/to/elements/foo/new-thing.bst
# In CI, pair --check-new with a diff against the PR's base branch to
# scope it to files actually touched by the PR, e.g.:
# git diff --name-only --diff-filter=AM origin/main... -- '*.bst'
Exits 0 unless a structural error is found (invalid YAML, missing kind:).
Unknown-kind and unconfirmed-dependency findings are warnings by default
since they're "go check this" signals, not certain failures. Pass --strict
to make unconfirmed-dependency findings fatal once you've built confidence
in their false-positive rate; unknown-kind warnings remain non-fatal.
Scope
This repo owns the planning + core + parallel dependency chunks — the mechanically identical, highest-churn part of the pipeline. It does not own:
build_final(export,bootc container lint, GHCR push, cosign signing, Trivy scan) — kept in each consuming repo. Cosign's keyless signing embeds the calling workflow's identity in the Fulcio certificate; if signing happened inside this shared workflow instead, every consumer's published signature would carrytuna-os/bst-ci's identity instead of their own, breaking the verification instructions in each repo's README.- ISO building, Containerfiles, dracut modules, install scripts — these differ meaningfully per desktop (different base images, different live-session setup) and aren't good candidates for a shared abstraction.
Versioning
Consumers can pin the workflow by commit SHA:
jobs:
multirunner:
uses: tuna-os/bst-ci/.github/workflows/multirunner-build.yml@<sha>
with:
image_name: your-image
bst_target: oci/your-image.bst
Two caveats, both of which matter when you are pinning in order to be able to roll back:
- This repository publishes no tags or releases yet, so a SHA is the only
ref that resolves. Both current consumers track
main. - A pin freezes the workflow definition only. The
planningjob checks the helper scripts out into.bst-ciwithref: mainhardcoded, so a pinned consumer still runs the currentscripts/ci-build-matrix.py— the script that computes chunk names, the chunk matrix and the GHCR cache keys. There is no input for overriding that ref; passing one that the workflow does not declare fails the run at load time.
runbooks/rollback.md covers what to do when a change here breaks a consumer,
which of the two rollback levers covers which kind of regression, and the cache
blast radius to expect afterwards.