Skip to content

Commit 7ad87d3

Browse files
JSONboredJSONbored
andauthored
fix(ci): hold the Codecov verdict until every upload has landed (#9807)
`notify.after_n_builds` fires on the Nth upload to ARRIVE, and arrival order does not match importance. validate-code's rees / control-plane / engine reports land in ~2 minutes; validate-tests is still producing the backend lcov that covers most of src/**. So codecov/patch posted a conclusion computed from a partial report — on #9799 it went pass, then fail, while validate-tests was still pending, with Codecov's own comment printing `backend | BASE 1 | HEAD 0`. That is not cosmetic: the gate closes a contributor PR on any red check, codecov/patch included, so a transient partial-report red can close a PR that has no defect. `require_ci_to_pass` gates the final verdict but does not suppress the interim status, and `wait_for_ci` (already on by default) did not prevent it either. Raising the count cannot fix this — the reasoning preserved on `after_n_builds` explains why a floor above the guaranteed minimum leaves codecov/patch permanently un-posted on PRs that produce fewer uploads — because a count cannot express "wait for the backend upload specifically". `manual_trigger` can: Codecov sends nothing until `codecovcli send-notifications` runs, and the new codecov-notify job runs it once, after every uploading job has finished. The notifier is deliberately unable to fail a PR. It is not in `validate`'s needs, and every failure path — a missing pipx, a failed install, a rejected notification — warns and exits 0. If it never runs, no status posts, the check set stays incomplete and the gate waits: it cannot merge unreviewed and cannot false-close, which is the whole point. `!cancelled()` rather than `always()` so a run superseded by a newer push does not notify on a stale sha, and the job skips entirely when neither upload job ran, since notifying on a commit Codecov has no reports for is an error rather than a verdict. Closes #9801 Co-authored-by: JSONbored <aetherealdev@gmail.com>
1 parent dde80af commit 7ad87d3

3 files changed

Lines changed: 197 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,3 +1275,60 @@ jobs:
12751275
echo "::error title=CI::A required CI job failed or was cancelled."
12761276
echo "Job results: ${{ join(needs.*.result, ', ') }}"
12771277
exit 1
1278+
1279+
# Codecov sends nothing until it is told the uploads are done (codecov.yml's notify.manual_trigger).
1280+
# This job is the one place that can truthfully say "that's all of them": it needs every uploading job
1281+
# (validate-code carries rees / control-plane / engine, validate-tests carries backend), so it cannot
1282+
# run before they have finished.
1283+
#
1284+
# Before this existed, notify.after_n_builds fired the verdict on the first upload to ARRIVE, and
1285+
# arrival order does not match importance -- validate-code finishes in ~2 minutes while validate-tests
1286+
# is still producing the backend lcov. codecov/patch therefore posted a conclusion computed from a
1287+
# partial report (#9801).
1288+
#
1289+
# Deliberately NOT in `validate`'s needs, and deliberately non-fatal. The bug being fixed is a phantom
1290+
# red closing a good PR, so the fix must not add a new way to do that: a Codecov delivery hiccup must
1291+
# never fail the required check. If the trigger does not run, no status posts, the gate sees an
1292+
# incomplete check set and waits -- it cannot merge unreviewed and cannot false-close.
1293+
codecov-notify:
1294+
name: codecov-notify
1295+
needs: [changes, validate-code, validate-tests]
1296+
# Skip only when neither uploading job ran: a fully path-filtered PR uploads nothing, and asking
1297+
# Codecov to notify on a commit it has no reports for is an error rather than a verdict. Uses
1298+
# !cancelled() rather than always() so a run superseded by a newer push does not notify on a stale
1299+
# sha -- the replacement run posts the real verdict.
1300+
if: ${{ !cancelled() && (needs.validate-code.result != 'skipped' || needs.validate-tests.result != 'skipped') }}
1301+
runs-on: ubuntu-latest
1302+
timeout-minutes: 5
1303+
permissions:
1304+
contents: read
1305+
steps:
1306+
# Every failure path below is swallowed on purpose. A red check from THIS job would be closed by
1307+
# the gate, which is precisely the bug being fixed -- the notifier must never be able to fail a PR.
1308+
# Worst case is that no verdict posts, leaving the check set incomplete, and the gate waits.
1309+
- name: Release the Codecov verdict
1310+
env:
1311+
# Empty on fork PRs, where Actions withholds secrets. That matches the tokenless upload steps
1312+
# in validate-code / validate-tests, so pass --token only when there is one to pass.
1313+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
1314+
# MUST match the uploads' override_commit exactly (head sha on a PR, github.sha on push) or the
1315+
# notification targets a commit that has no reports attached to it.
1316+
COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
1317+
SLUG: ${{ github.repository }}
1318+
# Pinned like every action SHA in this file -- the CLI reaches the same Codecov API the uploads do.
1319+
CODECOV_CLI_VERSION: "11.3.1"
1320+
run: |
1321+
if ! pipx install "codecov-cli==${CODECOV_CLI_VERSION}"; then
1322+
echo "::warning title=Codecov::could not install codecov-cli==${CODECOV_CLI_VERSION}; no verdict will post for ${COMMIT_SHA}."
1323+
exit 0
1324+
fi
1325+
args=(--commit-sha "$COMMIT_SHA" --slug "$SLUG" --git-service github --fail-on-error)
1326+
# `[ -n "$X" ] && args+=(...)` would abort the step under `bash -e` when the token is absent.
1327+
if [ -n "$CODECOV_TOKEN" ]; then
1328+
args+=(--token "$CODECOV_TOKEN")
1329+
fi
1330+
# --fail-on-error makes the CLI exit non-zero so a real failure is visible in the log; catching
1331+
# it keeps that visibility without ever turning this check red.
1332+
if ! codecovcli send-notifications "${args[@]}"; then
1333+
echo "::warning title=Codecov::send-notifications failed for ${COMMIT_SHA} -- codecov/patch will not post for this run."
1334+
fi

codecov.yml

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,32 @@ codecov:
1111
# Don't post a verdict until the CI run that produced the report has finished.
1212
require_ci_to_pass: true
1313
notify:
14-
# The count that ALWAYS lands whenever validate-tests runs: since the 2026-07-24 unsharding, the
15-
# backend suite produces ONE whole-suite lcov upload (flags: backend) instead of the former 3 shard
16-
# uploads this threshold was sized for. It must stay 1, not 2, even though review-enrichment /
17-
# control-plane add their own flag uploads on PRs that touch them -- those uploads only exist on such
18-
# PRs, and a floor above the guaranteed minimum would leave codecov/patch permanently un-posted on
19-
# every other PR. The narrow premature-window this reintroduces (a rees/control-plane upload landing
20-
# and posting before the ~25-minute backend run finishes) self-corrects on the backend upload, and
21-
# require_ci_to_pass above still holds the final verdict to the run's own conclusion.
14+
# Hold every status and comment until CI says the uploads are done, rather than guessing from a
15+
# count (#9801). `after_n_builds` fires on the Nth upload to ARRIVE, and arrival order does not
16+
# match importance: validate-code's rees / control-plane / engine reports land in ~2 minutes while
17+
# validate-tests is still running the backend lcov that covers most of src/**. So the verdict was
18+
# computed from a partial report -- codecov/patch went pass, then fail, while validate-tests was
19+
# still pending, with Codecov's own comment printing `backend | BASE 1 | HEAD 0`.
20+
#
21+
# That is not cosmetic here: the gate closes a contributor PR on ANY red check, codecov/patch
22+
# included, so a transient partial-report red can close a PR that has no defect.
23+
#
24+
# Raising the count cannot fix it -- see the reasoning preserved on after_n_builds below -- because
25+
# a count cannot express "wait for the backend upload specifically". manual_trigger can: Codecov
26+
# sends nothing until `codecovcli send-notifications` runs, and the codecov-notify job in
27+
# .github/workflows/ci.yml runs that once, after every uploading job has finished.
28+
#
29+
# Failure mode if the trigger never runs: codecov/patch simply never posts, so the gate sees an
30+
# incomplete check set and waits. That is fail-safe -- it cannot merge unreviewed and cannot
31+
# false-close -- which is the whole point of the change.
32+
manual_trigger: true
33+
# Inert while manual_trigger is true, and kept deliberately so removing manual_trigger restores the
34+
# correct value rather than the default. The count that ALWAYS lands whenever validate-tests runs:
35+
# since the 2026-07-24 unsharding, the backend suite produces ONE whole-suite lcov upload
36+
# (flags: backend) instead of the former 3 shard uploads this threshold was sized for. It must stay
37+
# 1, not 2, even though review-enrichment / control-plane add their own flag uploads on PRs that
38+
# touch them -- those uploads only exist on such PRs, and a floor above the guaranteed minimum would
39+
# leave codecov/patch permanently un-posted on every other PR.
2240
after_n_builds: 1
2341

2442
coverage:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { readFileSync } from "node:fs";
2+
import { parse } from "yaml";
3+
import { describe, expect, it } from "vitest";
4+
5+
function readYaml(path: string): Record<string, unknown> {
6+
return record(parse(readFileSync(path, "utf8")), path);
7+
}
8+
9+
function record(value: unknown, label: string): Record<string, unknown> {
10+
if (!value || typeof value !== "object" || Array.isArray(value)) {
11+
throw new Error(`${label} must be an object`);
12+
}
13+
return value as Record<string, unknown>;
14+
}
15+
16+
function steps(job: Record<string, unknown>): Array<Record<string, unknown>> {
17+
return (job.steps as Array<Record<string, unknown>>) ?? [];
18+
}
19+
20+
// Regression guard for #9801: codecov/patch posted a red conclusion computed from a PARTIAL report.
21+
// notify.after_n_builds fires on the Nth upload to ARRIVE, and arrival order does not match importance --
22+
// validate-code's rees/control-plane/engine reports land in ~2 minutes while validate-tests is still
23+
// producing the backend lcov that covers most of src/**. Codecov's own comment printed
24+
// `backend | BASE 1 | HEAD 0` while it was posting a failure. Because the gate closes a contributor PR on
25+
// ANY red check, codecov/patch included, that transient red could close a PR with no defect.
26+
//
27+
// The fix holds all notifications (notify.manual_trigger) until the codecov-notify job releases them once
28+
// every uploading job has finished. These assertions pin the parts that are load-bearing and silent when
29+
// broken -- a wrong commit sha or a resurrected count would fail open, not loudly.
30+
describe("codecov notifications wait for every upload (#9801)", () => {
31+
const workflow = readYaml(".github/workflows/ci.yml");
32+
const jobs = record(workflow.jobs, "workflow.jobs");
33+
const codecov = readYaml("codecov.yml");
34+
35+
it("codecov.yml holds every status until the CLI releases it", () => {
36+
const notify = record(record(codecov.codecov, "codecov").notify, "codecov.notify");
37+
expect(notify.manual_trigger).toBe(true);
38+
// Kept deliberately so dropping manual_trigger restores the correct floor rather than the default.
39+
expect(notify.after_n_builds).toBe(1);
40+
});
41+
42+
it("codecov-notify waits on every job that uploads a report", () => {
43+
const job = record(jobs["codecov-notify"], "jobs.codecov-notify");
44+
// validate-code carries rees/control-plane/engine; validate-tests carries backend. Missing either
45+
// reintroduces the partial-report verdict this job exists to prevent.
46+
expect(job.needs).toEqual(["changes", "validate-code", "validate-tests"]);
47+
});
48+
49+
it("codecov-notify still runs when an upload job fails, but not on a superseded run", () => {
50+
const condition = String(record(jobs["codecov-notify"], "jobs.codecov-notify").if);
51+
// A failed validate-tests must still release the verdict, otherwise codecov/patch never posts at all.
52+
expect(condition).toContain("!cancelled()");
53+
// always() would notify on a sha the replacement run has already superseded.
54+
expect(condition).not.toContain("always()");
55+
// A fully path-filtered PR uploads nothing; notifying on a commit Codecov has no reports for errors.
56+
expect(condition).toContain("needs.validate-code.result != 'skipped'");
57+
expect(condition).toContain("needs.validate-tests.result != 'skipped'");
58+
});
59+
60+
it("codecov-notify is NOT a dependency of the required validate check", () => {
61+
// The bug being fixed is a phantom red closing a good PR. Wiring the notifier into the required
62+
// check would let a Codecov delivery hiccup do exactly that.
63+
expect(record(jobs.validate, "jobs.validate").needs).not.toContain("codecov-notify");
64+
});
65+
66+
it("a failed send-notifications warns instead of failing the job", () => {
67+
const run = String(steps(record(jobs["codecov-notify"], "jobs.codecov-notify")).at(-1)?.run ?? "");
68+
// The CLI exits non-zero (--fail-on-error) so a real failure is visible, and the `if !` converts it
69+
// to a warning. Without the guard the step would go red and re-create the false-close class.
70+
expect(run).toContain("--fail-on-error");
71+
expect(run).toContain("if ! codecovcli send-notifications");
72+
expect(run).toContain("::warning title=Codecov::");
73+
});
74+
75+
it("the token flag is only added when a token exists (fork PRs run without secrets)", () => {
76+
const run = String(steps(record(jobs["codecov-notify"], "jobs.codecov-notify")).at(-1)?.run ?? "");
77+
// `[ -n "$X" ] && args+=(...)` aborts the whole step under `bash -e` when the token is empty, which
78+
// is exactly the fork-PR case -- it must stay an if-block.
79+
expect(run).toContain('if [ -n "$CODECOV_TOKEN" ]; then');
80+
expect(run).not.toMatch(/\[ -n "\$CODECOV_TOKEN" \] &&/);
81+
});
82+
83+
it("codecov-notify targets the same commit the uploads used", () => {
84+
const notifyStep = steps(record(jobs["codecov-notify"], "jobs.codecov-notify")).at(-1);
85+
const sha = String(record(notifyStep?.env, "codecov-notify.env").COMMIT_SHA);
86+
// The uploads all pass override_commit: head sha on a PR, github.sha on push. A mismatch here points
87+
// the notification at a commit with no reports attached, so nothing ever posts.
88+
const expected = "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}";
89+
expect(sha).toBe(expected);
90+
91+
const uploadOverrides = steps(record(jobs["validate-tests"], "jobs.validate-tests"))
92+
.filter((step) => String(step.name ?? "").startsWith("Upload coverage to Codecov") && !String(step.name).includes("fork"))
93+
.map((step) => String(record(step.with, "step.with").override_commit));
94+
expect(uploadOverrides.length).toBeGreaterThan(0);
95+
for (const override of uploadOverrides) expect(override).toBe(expected);
96+
});
97+
98+
it("the Codecov CLI is pinned like every other third-party dependency in the workflow", () => {
99+
const env = record(steps(record(jobs["codecov-notify"], "jobs.codecov-notify")).at(-1)?.env, "codecov-notify.env");
100+
expect(String(env.CODECOV_CLI_VERSION)).toMatch(/^\d+\.\d+\.\d+$/);
101+
});
102+
103+
it("no step in codecov-notify can turn the check red", () => {
104+
// The gate closes a contributor PR on ANY red check, so a failed notifier would re-create the very
105+
// bug it fixes. Every failure path -- install included -- has to be swallowed.
106+
const job = record(jobs["codecov-notify"], "jobs.codecov-notify");
107+
const runs = steps(job).map((step) => String(step.run ?? ""));
108+
expect(runs).toHaveLength(1);
109+
// A bare `pipx install` would fail the step (and the check) when the runner image lacks the CLI.
110+
expect(runs[0]).toContain("if ! pipx install");
111+
expect(runs[0]).toContain("exit 0");
112+
for (const step of steps(job)) expect(step.uses).toBeUndefined();
113+
});
114+
});

0 commit comments

Comments
 (0)