fix(runner): give parallel shards CI_NODE_INDEX and CI_NODE_TOTAL

What this MR does and why?

CI_NODE_INDEX was never implemented, and CI_NODE_TOTAL was emitted from a branch no job could reach — it is guarded on cfg.Parallel, which ExpandMatrixJobs sets to nil on every shard before planning. Every shard of a parallel: job ran with both variables unset, so a test-splitting command (pytest --splits $CI_NODE_TOTAL --group $CI_NODE_INDEX, knapsack, rspec splitters) fell back to running the whole suite: all shards did identical work, all passed, and the pipeline was green while proving nothing about the split.

The shard ordinal existed only inside the generated job name (test 1/4) and was discarded. Expansion now records each shard's 1-based index and shard total on the job, and runner.FromConfig emits them as predefined job variables. Both forms are covered — parallel: N and parallel: matrix:.

Semantics follow GitLab (lib/gitlab/ci/variables/builder.rb#L199-201, #L298-302):

  • CI_NODE_INDEX — 1-based, emitted only for jobs produced by parallel:.
  • CI_NODE_TOTAL — emitted for every job; the shard count, or 1 for a job without parallel:.

The 1 is a default rather than a derived fact, so it yields to a value the pipeline set itself, the same way GIT_DEPTH already defers to user config. A shard's own total always wins — a deliberate deviation from GitLab, where job variables: outrank predefined ones, because reporting the shard count actually scheduled beats honouring a value that would desynchronise the split. Both deviations are noted in the code.

glci variables reports the same two variables. It already derives CI_JOB_NAME and CI_JOB_STAGE because those are known without running anything, and the node variables are too — the shard name encodes them.

The unit test covering the old branch passed only because it hand-built a JobConfig with Parallel populated and never went through expansion. It is replaced by tests that exercise the real path, including a guard that JobConfig.Parallel alone drives nothing, and an e2e test that asserts the values inside running containers.

Worth recording how this survived: e2e/testdata/comprehensive/ci/jobs.yml already echoes NODE=$CI_NODE_INDEX/$CI_NODE_TOTAL, but the compat harness only checks job status and never inspects traces, so that line printed NODE=/ and passed. It looked like coverage and asserted nothing.

Steps to reproduce

test:
  parallel: 4
  image: alpine:latest
  script:
    - echo "index=$CI_NODE_INDEX total=$CI_NODE_TOTAL"

Run the pipeline and read a shard's trace with glci log.

  • Before: every shard prints index= total=.
  • After: each shard prints a distinct index=1index=4, all with total=4.

A job without parallel: now prints total=1 with CI_NODE_INDEX unset, matching GitLab.

glci variables --all shows both values per shard, sourced as predefined:job.

Closes #139 (closed)

Merge request reports

Loading
Loading