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 byparallel:.CI_NODE_TOTAL— emitted for every job; the shard count, or1for a job withoutparallel:.
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=1…index=4, all withtotal=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.
Relevant issues and other links
Closes #139 (closed)
- GitLab variable emission:
lib/gitlab/ci/variables/builder.rb - Shard attributes:
number_strategy.rb,matrix_strategy.rb