Which Attempt an Artefact Belongs To

A re-run does not replace the previous attempt's artefacts — it adds to them. Everything below follows from that one fact, and from a second: nothing GitHub returns about an artefact says which attempt produced it.

What it cost (measured, 2026-09-14, run 34823051999)

Consolidate test results is this repository's ONE required check. On PR #4292, head 75ae0835:

attempt Run tests (shard 1) Consolidate test results
1 ❌ one failing test (an unattributed failure, #4300) ❌ correct
2 — rerun-failed-jobs ✅ 1454 / 1454 re-declared the same failing test
3 — rerun-failed-jobs of the consolidate alone ❌ the same, three minutes later

The run held two artefacts under one name:

id 10338854604  size 297521  created 08:38:28Z   ← attempt 1, carries the failure
id 10338734845  size 298484  created 08:49:55Z   ← attempt 2, green

actions/download-artifact de-duplicates a name collision by keeping the highest artefact ID, and artefact IDs are not monotonic across attempts: attempt 2's ID is the lower of the two. So the consolidate downloaded attempt 1's trx, re-declared the failure the re-run had just cleared, and did it again on the next re-run. That run could not be made green by any re-run — only by a new head.

A required check whose recovery path is a no-op turns every unattributed failure and every infrastructure hiccup into a forced new push. It compounds with anything that races on head freshness: on the same day, half of the day's CD runs were evicted by a newer head (#4322), and a forced new push is what feeds that race.

Why the name, and not the API

GET /repos/{owner}/{repo}/actions/runs/{id}/artifacts returns, per artefact: id, name, size_in_bytes, created_at, updated_at, expires_at, expired, digest and a workflow_run block of {id, repository_id, head_repository_id, head_branch, head_sha}.

There is no attempt field — measured on the two artefacts above. A consumer therefore cannot attribute an artefact to an attempt from anything the platform tells it. The producer has to say so, and the NAME is the only channel it has.

created_at orders those two correctly and is still the wrong instrument, for the same reason the ID is: it answers which upload is newer, not which attempt is this shard's outcome. A rerun-failed-jobs re-runs only the failed jobs, so five shards keep their attempt-1 artefacts and one has an attempt-2 artefact — a legitimate MIXTURE of attempts, in which "take the newest upload" and "take the current attempt" are both wrong. Only the highest attempt per shard is right.

The rule

Evidence carries its attempt in the name. Transport overwrites.

shard test results shard build output
what it is EVIDENCE — the trx a verdict is read from TRANSPORT — binaries, superseded the moment they are rebuilt
older attempt must SURVIVE and stay attributable (forensics) worthless, and dangerous if read (you would test code this attempt did not build)
name testResults-shard<N>-attempt<run_attempt> build-output-<N>
collision cannot happen — the name is unique per attempt cannot happen — overwrite: true deletes the name it replaces
consumer resolve-shard-attempts.py keeps the highest attempt per shard plain name: — only one can exist

Both halves remove the ambiguity rather than resolving it after the fact. Deleting the stale artefact in a cleanup step is the same fix from the wrong end: it leaves a window in which the consolidate can still read it, and it makes the read depend on a race.

This is the same remedy the fleet already applies to the other artefact-name collision: in node-repo-module-pack.yml a repository may invoke the reusable lane twice in one run, two artefacts named workspace-build coexisted, download-artifact resolved the name to one of them, and a required gate flipped green/red on main with no source change (Plugins#1077). When two producers can land one name in one run, the discriminator goes INTO the name, derived from the producing context rather than trusted from a caller.

The rate, with its denominator

Swept 2026-09-14 over every dotnet-test.yml run available in the API window:

runs swept (2026-09-12T07:33Z → 2026-09-14T16:32Z) 700
with run_attempt > 1 15 (2.1 %)
of those, carrying a duplicated testResults-shard* name 9
of those 9, where the ID-pick resolves to the OLDER upload 1
carrying a duplicated build-output-* name 0

So the consolidate read a superseded artefact in 1 of 9 duplicated runs — the ID ordering decides it, and it is a coin toss. The build-output half is latent, not hypothetical: zero occurrences only because rerun-failed-jobs re-ran shards and never build; a whole-workflow re-run re-runs build too, and a shard would then extract whichever tarball drew the higher ID.

Measuring a run for it

gh api "repos/Systemorph/MeshWeaver/actions/runs/<run_id>/artifacts?per_page=100" \
  --jq '.artifacts[] | "\(.id) \(.name) \(.size_in_bytes) \(.created_at)"' | sort -k2

Two rows with the same name is the collision. Compare max by id against max by created_at: when they differ, a by-name download resolved to the superseded upload. run_attempt itself comes from the run: gh api repos/{o}/{r}/actions/runs/<run_id> --jq '.run_attempt'.

After the fix there is nothing to compare: every name is distinct, and the consolidate PRINTS what it resolved —

shard 0: attempt 1 (its only upload)
shard 1: attempt 2 — superseding attempt(s) 1
resolved 6 shard directories to the newest attempt (run attempt 2); 1 superseded upload(s) dropped

— which is the positive, specific success signal the run previously had no way to give.

What the resolver refuses, and what it deliberately does not

.github/scripts/resolve-shard-attempts.py runs in collect-results, straight after the download, with its own self-test one step ahead of it (an unproven resolver is no resolver). It REDS on:

It says nothing about a shard that reported nothing: that is Every shard must have reported's verdict, which names which sixth of the suite is missing. One red per cause, and the script never becomes a second, weaker copy of a gate that already exists.

Reading CI Signals — what a check's colour is worth · The Merge Queue — where a queue red lives, and why the steward reads a shard's trx rather than the run's colour · Artifact Retention Interlock — how long these artefacts exist at all

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.