mix nbpr.matrix (NBPR v0.3.1)

Copy Markdown View Source

Generates the prebuild matrix for CI: combinations of an nbpr_* package under packages/ with a target system declared in the workspace mix.exs @prebuild_systems map.

mix nbpr.matrix [--json] [--changed-since <ref>] [--root <path>]

Scoping by change

Rebuilding an artefact whose cache key hasn't moved achieves nothing — mix nbpr.publish treats a published tarball as immutable and short-circuits — so CI passes --changed-since <ref> and gets only the work the diff implies:

  • a packages/nbpr_<name>/ file changed — that package, every target. Its version or metadata moved, so every target's artefact is stale.
  • a @prebuild_systems pin in the workspace mix.exs changed — every package, but only the targets whose version moved. The system version is part of the cache key, so a bump invalidates that target's artefacts across the board and leaves every other target alone.
  • anything else that can change how a build runs — the :nbpr library itself, this workflow — every package against --default-target only. A library change doesn't invalidate a cache key, so this is smoke coverage rather than a rebuild: broad across packages, one target deep.

Entries are deduplicated, so a package that qualifies twice is built once per target.

Without --changed-since the full cross-product is emitted, which is what workflow_dispatch wants for a deliberate from-scratch rebuild.

GitHub's 256-configuration cap

GitHub refuses to expand a single job's strategy past 256 configurations. Two probes established what's actually enforced, since the documented "256 jobs per workflow run" isn't it:

  • two sibling jobs of 200 and 100 configurations expanded all 300 — so the cap is per strategy, not per run;
  • an outer matrix of 2 driving a reusable workflow with a 200-entry inner matrix expanded all 400 — so strategy is honoured on a uses: job, and each instantiated inner strategy gets its own budget.

So --slices emits the outer half of a two-level matrix, one entry per target, each carrying its own inner matrix. Capacity becomes 256 targets × 256 packages instead of 256 in total, and adding packages never needs this revisited.

--max still guards each slice, because an oversized strategy fails the run at strategy-evaluation time with no failing check to show for it: the run goes red while every check stays green and branch protection waves it through.

Output

Without flags: human-readable lines, one per matrix entry.

With --json: a single line of GitHub Actions matrix JSON, ready to feed into a job's strategy.matrix via dynamic-matrix:

jobs:
  generate:
    outputs:
      matrix: ${{ steps.gen.outputs.matrix }}
    steps:
      - id: gen
        run: echo "matrix=$(mix nbpr.matrix --json)" >> "$GITHUB_OUTPUT"

  build:
    needs: generate
    strategy:
      matrix: ${{ fromJson(needs.generate.outputs.matrix) }}
    steps:
      - run: MIX_TARGET=${{ matrix.target }} mix nbpr.build ${{ matrix.module }} -o out/

An empty matrix is a legitimate result — a docs-only diff builds nothing. {"include": []} makes GitHub skip the job, so gate it on --count instead if the distinction matters to the workflow.

Flags

  • --json — emit {"include": [...]} JSON suitable for GHA dynamic matrix
  • --slices — emit the outer matrix of a two-level fan-out: one entry per target, each carrying its target's inner matrix as a string. This is what CI feeds to build-slice.yml.
  • --count — emit just the number of entries, for a workflow that needs to know whether there's work before defining a job
  • --changed-since <ref> — scope to the work implied by git diff <ref>...HEAD, per the rules above
  • --default-target <target> — the target used for smoke coverage of library and workflow changes (defaults to rpi4)
  • --target <target> — restrict to one target
  • --package <name> — restrict to one package, with or without the nbpr_ prefix
  • --max <n> — refuse to emit more than n entries (defaults to 256, GitHub's per-job cap). 0 disables the check. Failing here is the point: an oversized matrix makes GitHub fail the run at strategy-evaluation time, which produces no failing check and so doesn't block a merge.
  • --root <path> — workspace root (defaults to current directory). Useful when running this task from a script that doesn't cd first.

Summary

Functions

Returns the targets whose @prebuild_systems pin differs between two mix.exs texts — the set whose artefacts a version bump invalidated.

Filters entries down to the work implied by a diff. Pure, so the scoping rules are testable without a git repository.

Groups entries into one slice per target, each carrying its own inner matrix JSON as a string.

Functions

changed_targets(previous_mix_exs, current_mix_exs)

@spec changed_targets(String.t(), String.t()) :: [String.t()]

Returns the targets whose @prebuild_systems pin differs between two mix.exs texts — the set whose artefacts a version bump invalidated.

select(entries, changed_paths, changed_targets, default_target)

@spec select([map()], [String.t()], [String.t()], String.t()) :: [map()]

Filters entries down to the work implied by a diff. Pure, so the scoping rules are testable without a git repository.

changed_paths are repo-relative paths. changed_targets are the targets whose @prebuild_systems pin moved. default_target carries the smoke coverage for changes that affect how builds run without invalidating any cache key.

slices(entries, opts \\ [])

@spec slices(
  [map()],
  keyword()
) :: [map()]

Groups entries into one slice per target, each carrying its own inner matrix JSON as a string.

This is the outer half of a two-level matrix. GitHub caps a single job's strategy at 256 configurations, but a reusable workflow called from a matrix'd job instantiates a fresh inner job with its own budget — verified by probe: an outer matrix of 2 driving an inner matrix of 200 expanded all

  1. So capacity multiplies rather than adds, and the cap stops being something this repo has to plan around.

Slicing by target rather than by arbitrary shards means a slice is exactly one (system, system_version), which is what a build job's caches are keyed on anyway. The inner matrix is embedded as a string deliberately: the workflow then reads ${{ matrix.matrix }} straight out of the outer matrix context, with no JSON-object indexing in a with: block.