Skip to content

payload-snapshot: fix silent data loss on unauthenticated gcloud (supersedes #641) - #644

Merged
openshift-merge-bot[bot] merged 7 commits into
openshift-eng:mainfrom
not-stbenjam:fix-silent-snapshot-data-loss
Jul 26, 2026
Merged

payload-snapshot: fix silent data loss on unauthenticated gcloud (supersedes #641)#644
openshift-merge-bot[bot] merged 7 commits into
openshift-eng:mainfrom
not-stbenjam:fix-silent-snapshot-data-loss

Conversation

@not-stbenjam

@not-stbenjam not-stbenjam commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Supersedes and absorbs #641 — its timeout raise and bounded-depth fallback are included here, with two bugs in the fallback fixed.

Problem

A real 5.0 snapshot contained no CI artifact data at all:

XML files anywhere in snapshot:  0
build_log.json files:            0
results.json files:             15   (all empty)

Every gcloud-sourced artifact was missing; every HTTPS-sourced artifact (release controller, changelog, GitHub PR data) was intact.

Root cause: gcloud storage refuses client-side when no account is configured. The artifact buckets are public, but gcloud does not attempt an anonymous read — it fails with "You do not currently have an active account selected" without ever issuing the request. _check_gcloud() only ran gcloud --version, so an unauthenticated install passed preflight and then failed every read.

Demonstrated:

$ CLOUDSDK_CONFIG=/tmp/empty gcloud storage cat gs://test-platform-results/.../junit_operator.xml
ERROR: (gcloud.storage.cat) You do not currently have an active account selected.

$ curl -o /dev/null -w '%{http_code}\n' https://storage.googleapis.com/test-platform-results/.../junit_operator.xml
200

Second defect, which turned that into silent corruption: _run_gcloud() returned None identically for a timeout, a missing binary, an auth refusal, and a genuine "no such object", and JUnitCollector.collect() wrote [] regardless. So the snapshot reported test_failure_count: 0 for jobs whose data it had simply failed to read. Downstream, "0 test failures" on a failed job reads as "it failed for reasons unrelated to its tests", which sends root-cause analysis down a false path.

Consequence: four consecutive payload analyses attributed a 50-hour stream outage to the wrong PRs, including an innocent candidate scored 80/100. The real cause was in a payload the analyses had already searched past.

Fix

1. Don't fail in the first place. When gcloud has no active account, set CLOUDSDK_AUTH_DISABLE_CREDENTIALS so it reads the public buckets anonymously. Resolved once under a lock (collectors run in a thread pool). Authentication is now optional, not a silent prerequisite.

2. Make any remaining failure loud, not empty.

  • _run_gcloud/_run_gcloud_bytes classify failures (auth, timeout, gcloud_missing, command_failed) and record them. A genuine "matched no objects" stays absence, not error — which matters because the fallback below probes many paths that legitimately don't exist.
  • When JUnit cannot be read, results.json is not written; the summary omits test_failure_count and sets junit_collection_failed: true. Absent means unknown; 0 means verified clean.
  • summary.json gains data_complete and collection_errors[]; AGENTS.md gains an ⚠️ INCOMPLETE SNAPSHOT section so the analysis agent sees the gap in the document it reads first.
  • Loud end-of-run summary, plus opt-in --fail-on-incomplete for automation.

3. JUnit discovery hardening (absorbed from #641).

  • Recursive glob timeout 30 s → 120 s, plus a bounded-depth fallback when the glob yields nothing.
  • Fixed while absorbing: payload_snapshot: fix JUnit XML discovery timeout for large artifact trees #641's probes covered {step}/, {step}/artifacts/ and {step}/*/artifacts/ only. Aggregated jobs keep junit-aggregated.xml about six levels below artifacts/, so the fallback could never find it — precisely for the jobs in the affected snapshot. Added a ** probe scoped to the aggregator subtree.
  • Fixed while absorbing: junit_operator.xml sits directly in artifacts/, not in a step directory. Added a top-level probe; the fallback now recovers the same file set as the glob (73 failing tests either way, vs 71 without).
  • Failures a fallback recovers are flagged recovered: true and excluded from data_complete, so a tuned-away timeout stays visible as diagnostics without condemning a complete snapshot.

4. Analysis side. payload-analysis now derives a failure mode's originating payload from the per-test first_failed_in rather than the job-level streak, which merges unrelated modes (infra blip, flake, real regression) and so scores candidates from a payload predating the regression. On a collection failure the agent is told to collect the job's artifacts itself rather than reason around the gap.

Verification

Same payload, four environments:

Environment Result
No gcloud credentials at all (CLOUDSDK_CONFIG empty) data_complete: true, 73 blocking test failures, test_failure_count 68/31, 8 XMLs, 4 build logs, exit 0 — previously 0 / 0 / 15 empty
Authenticated unchanged: 73 failures, 0 errors, no fallback needed, exit 0
Recursive glob fails, other reads fine fallback recovers 2 files/job → 73 failures, 4 errors all recovered: true, data_complete: true, exit 0 — identical to the glob path
All reads fail (403 on everything) 20 unrecovered errors, 0 results.json written, test_failure_count absent, junit_collection_failed: true, exit 1

Before/after on the same payload: JUnit XMLs 0 → 18, build_log.json 0 → 9, non-empty results.json 0 → 9, blocking test failures 0 → 73.

With the data present, the per-test onset now identifies the true originating payload automatically — the one that took hours of manual archive work to establish by hand:

first_failed_in=5.0.0-0.ci-2026-07-24-212127  payloads_failing=4
  [bz-config-operator] clusteroperator/config-operator must go Progressing=True during an upgrade

The job-level streak pointed two payloads earlier, at failures that were CI infrastructure (pod has not been scheduled in 1h, The node had condition: [DiskPressure]) where the upgrade phase never ran.

Note on #641's premise

The glob was not slow for the jobs in the affected snapshot — 0.73 s for the aggregated AWS job, 8 s for hypershift-e2e-aws — and gcloud failed on every read regardless of timeout. So #641 alone would not have prevented this, and its fallback could not have found the aggregated JUnit even when it ran. Both problems are real, which is why its changes are included here rather than dropped.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Snapshot collection now treats missing/unavailable data as “unknown” (not zero failures), and marks snapshots as incomplete when collection cannot be fully recovered.
    • Added --fail-on-incomplete to exit non-zero when unrecovered collection issues remain.
    • Improved diagnostics for missing, partial, or unreadable JUnit and build logs, including clearer completeness semantics.
    • Supports unauthenticated access when artifacts are publicly readable, with better reporting when tooling credentials are unavailable.
  • Documentation

    • Expanded payload analysis and snapshot completeness documentation to reflect the updated semantics.
  • Tests

    • Added regression tests covering the snapshot completeness contract and error-handling behaviors.
  • Chores

    • Bumped the CI plugin version to 0.0.72 (including published metadata).

A failed `gcloud storage` read returned None indistinguishably from "no
such object", and `JUnitCollector.collect()` then wrote `[]` to
results.json regardless. The snapshot therefore reported
`test_failure_count: 0` for jobs whose test data it had simply been
unable to read, and the analysis agent read that as "this job failed for
reasons unrelated to its tests".

Observed in a real 5.0 snapshot: every gcloud-sourced artifact was
missing (0 JUnit XMLs, 0 build_log.json, 15 empty results.json) while
every HTTP-sourced artifact was intact. `_check_gcloud()` only ran
`gcloud --version`, so an installed-but-unauthenticated gcloud passed
preflight and then failed every read silently.

Changes:

- `_run_gcloud`/`_run_gcloud_bytes` classify failures (auth, timeout,
  gcloud_missing, command_failed) and record them. A genuine "matched no
  objects" is still treated as absence, not error.
- JUnit collection no longer writes results.json when the data could not
  be read. The summary omits `test_failure_count` and sets
  `junit_collection_failed: true`, so absent means unknown and 0 means
  verified clean.
- summary.json gains `data_complete` and `collection_errors[]`.
- AGENTS.md gains an INCOMPLETE SNAPSHOT section so the analysis agent
  sees the gap in its orientation document.
- Loud end-of-run warning, plus `--fail-on-incomplete` for automation.
- `_check_gcloud_credentials()` warns when gcloud has no active account.

Also documents the invariant in both skills, and makes payload-analysis
derive a failure mode's originating payload from the per-test
`first_failed_in` rather than the job-level streak. The job-level streak
merges unrelated modes (infrastructure blips, flakes, real regressions),
which causes candidate PRs to be scored from a payload that predates the
actual regression.
@openshift-ci
openshift-ci Bot requested review from brandisher and stleerh July 26, 2026 13:13
@openshift-ci openshift-ci Bot added the needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. label Jul 26, 2026
@openshift-ci

openshift-ci Bot commented Jul 26, 2026

Copy link
Copy Markdown

Hi @not-stbenjam. Thanks for your PR.

I'm waiting for a openshift-eng member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Tip

We noticed you've done this a few times! Consider joining the org to skip this step and gain /lgtm and other bot rights. We recommend asking approvers on your previous PRs to sponsor you.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f9a96b5-0e99-450b-82ea-58c2c3a4b312

📥 Commits

Reviewing files that changed from the base of the PR and between 6993ef2 and a1d9138.

📒 Files selected for processing (4)
  • plugins/ci/skills/payload-analysis/SKILL.md
  • plugins/ci/skills/payload-snapshot/SKILL.md
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
  • plugins/ci/skills/payload-snapshot/scripts/test_collection_completeness.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py

Walkthrough

The CI payload snapshot flow now records gcloud collection failures, exposes incomplete snapshots through summary.json and agent guidance, preserves unknown JUnit results, adds --fail-on-incomplete, updates streak-analysis rules, and bumps the plugin version to 0.0.72.

Changes

Payload snapshot completeness

Layer / File(s) Summary
Collection failure tracking and recovery
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
Gcloud failures are classified and recorded; credential checks, bounded JUnit discovery fallback, and collector-level unavailable-data handling are added.
Incomplete snapshot outputs and CLI behavior
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py, plugins/ci/skills/payload-snapshot/SKILL.md
Generated summaries include data_complete and collection_errors; unavailable JUnit data is marked unknown, agent guidance identifies incomplete snapshots, and the CLI can fail on unrecovered errors.
Consumer guidance, scoring rules, regression tests, and release metadata
plugins/ci/skills/payload-analysis/SKILL.md, plugins/ci/skills/payload-snapshot/scripts/test_collection_completeness.py, .claude-plugin/marketplace.json, docs/index.html, plugins/ci/.claude-plugin/plugin.json
Documentation defines incomplete-data semantics and separates job-level streak onset from per-failure-mode onset; tests cover collection completeness; CI plugin version metadata is updated from 0.0.71 to 0.0.72.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: brandisher, stleerh

Sequence Diagram(s)

sequenceDiagram
  participant SnapshotCLI
  participant JUnitCollector
  participant Gcloud
  participant SummaryGenerator
  participant SummaryJSON
  SnapshotCLI->>Gcloud: check active credentials
  JUnitCollector->>Gcloud: discover and download JUnit files
  Gcloud-->>JUnitCollector: data or classified collection error
  JUnitCollector->>SummaryGenerator: provide collected data and errors
  SummaryGenerator->>SummaryJSON: write data_complete and collection_errors
  SnapshotCLI->>SnapshotCLI: fail when --fail-on-incomplete detects unrecovered errors
Loading
🚥 Pre-merge checks | ✅ 9 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (9 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: payload-snapshot fixes silent data loss when gcloud is unauthenticated.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Real People Names In Style References ✅ Passed PASS: I found no real-person names used as style references or examples in the changed commands/docs; only product/technical terms and placeholders.
No Assumed Git Remote Names ✅ Passed PR diff adds no new hardcoded git remote names; changed docs/code contain no origin/upstream assumptions, and the only git fetch origin is pre-existing.
Git Push Safety Rules ✅ Passed No changed file adds git push, force-push, or protected-branch push instructions; the PR is unrelated to pushing.
No Untrusted Mcp Servers ✅ Passed No changed file adds MCP server installs or npx/npm-based server dependencies; only payload-snapshot docs/code were touched.
Ai-Helpers Overlap Detection ✅ Passed No ≥60% overlap found: open PRs touching these paths have unrelated titles (#626/#627/#580), and nearby CI skills are functionally distinct.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@stbenjam

Copy link
Copy Markdown
Member

/ok-to-test
/test ?

@openshift-ci openshift-ci Bot added ok-to-test Indicates a non-member PR verified by an org member that is safe to test. and removed needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py (2)

2231-2236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Module-level mutable global isn't reset between invocations.

_COLLECTION_ERRORS is a plain module-level list with no reset hook. For a single CLI run this is fine, but the PR description mentions test scenarios covering both "incomplete snapshot" and "successful artifact collection" — if those are exercised in-process (e.g. via pytest calling into this module repeatedly without re-importing it), errors recorded by one test will leak into the next test's data_complete/collection_errors assertions.

Consider adding a small _reset_collection_errors() helper (or making this an attribute passed through Snapshotter) that test fixtures/main() can call at the start of a run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
2231 - 2236, The module-level _COLLECTION_ERRORS list persists across in-process
invocations and leaks failures between runs. Add a small
_reset_collection_errors() helper and invoke it at the start of each CLI/run
entry point, including main(), so data_complete and collection_errors reflect
only the current snapshot collection.

2268-2354: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Collection errors are double-recorded for the same underlying failure.

_run_gcloud/_run_gcloud_bytes already call _record_collection_error with a raw reason (auth, timeout, gcloud_missing, command_failed) for every failed invocation. JUnitCollector.collect() (Line 745) and BuildLogCollector._fetch() (Line 862) then add a second, semantic entry (junit_unavailable/build_log_unavailable) for the same event. Worse, in JUnitCollector.collect() this raw error is recorded once per file inside the download loop, so a systemic auth failure with 2 junit files produces 3 entries (2×auth + 1×junit_unavailable) for what is really a single credential problem. Across dozens of failed jobs in a broken-credentials run, collection_errors[] can balloon with near-duplicate entries (each carrying up to 500 chars of near-identical stderr in detail), inflating summary.json size and the "N collection error(s)" count shown in the CLI warning (Lines 2753-2754) far beyond the actual number of distinct problems.

Consider deduplicating identical (reason, stage, job) entries before appending, or suppressing the raw per-call reason once a semantic collector-level reason has been recorded for the same job/stage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
2268 - 2354, Prevent duplicate collection errors across _run_gcloud,
_run_gcloud_bytes, JUnitCollector.collect, and BuildLogCollector._fetch by
deduplicating entries using the same reason, stage, and job identity. Ensure
repeated per-file or per-call failures for one job/stage produce only one
recorded error while preserving distinct problems and the semantic
collector-level errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 2296-2302: Scope JUnit collection failures by payload tag. Update
_record_collection_error and _job_junit_failed to accept and match an optional
payload_tag, pass self.payload_tag from JUnitCollector’s collection error call,
and pass self.target_tag from _build_failed_job_details so repeated job names
across payloads cannot be misattributed.
- Around line 733-761: Track whether any JUnit download fails during collection,
rather than only checking whether downloaded equals zero. Update the guard
around _collection_error_count and results.json generation to treat any failed
download as incomplete, record the collection error, and leave results.json
absent so partial data is not reported as authoritative.

---

Nitpick comments:
In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 2231-2236: The module-level _COLLECTION_ERRORS list persists
across in-process invocations and leaks failures between runs. Add a small
_reset_collection_errors() helper and invoke it at the start of each CLI/run
entry point, including main(), so data_complete and collection_errors reflect
only the current snapshot collection.
- Around line 2268-2354: Prevent duplicate collection errors across _run_gcloud,
_run_gcloud_bytes, JUnitCollector.collect, and BuildLogCollector._fetch by
deduplicating entries using the same reason, stage, and job identity. Ensure
repeated per-file or per-call failures for one job/stage produce only one
recorded error while preserving distinct problems and the semantic
collector-level errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a8a770d-3479-490e-b983-30b14e82fc6d

📥 Commits

Reviewing files that changed from the base of the PR and between f55977e and 2b16d39.

📒 Files selected for processing (6)
  • .claude-plugin/marketplace.json
  • docs/index.html
  • plugins/ci/.claude-plugin/plugin.json
  • plugins/ci/skills/payload-analysis/SKILL.md
  • plugins/ci/skills/payload-snapshot/SKILL.md
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py

Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py Outdated
…ounded-depth fallback

Incorporates the JUnit discovery hardening from openshift-eng#641 (closed in favour of
this PR), with two fixes and one interaction resolved:

- Primary recursive glob timeout raised 30s -> 120s.
- Bounded-depth fallback when the recursive glob yields nothing.
- Fix: openshift-eng#641's probes checked {step}/, {step}/artifacts/ and
  {step}/*/artifacts/ only. Aggregated jobs keep junit-aggregated.xml
  about six levels below artifacts/, so those probes could never find it.
  The aggregator subtree now gets its own scoped ** probe, which stays
  fast because it covers one small directory.
- Fix: junit_operator.xml lives directly in artifacts/, not inside a step
  directory, so the fallback missed it. Added a top-level probe. The
  fallback now recovers the same file set as the glob.
- Interaction: a recovered failure must not mark the snapshot incomplete.
  Errors resolved by a fallback are flagged `recovered: true`, reported
  separately, and excluded from `data_complete`.

Verified against the same payload three ways:
- glob fails, fallback recovers: 73 failing tests, data_complete true,
  4 errors all recovered, exit 0 — identical results to the glob path
- all reads fail: 20 unrecovered errors, no results.json written,
  junit_collection_failed set, exit 1
- real gcloud: no fallback needed, 73 failing tests, 0 errors, exit 0
@not-stbenjam not-stbenjam changed the title payload-snapshot: never report unreadable data as empty payload-snapshot: never report unreadable data as empty (supersedes #641) Jul 26, 2026
@stbenjam

Copy link
Copy Markdown
Member

/ok-to-test

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Line 795: Rename the ambiguous single-letter loop variable l in the list
comprehensions around the payload snapshot processing at the referenced
locations to a descriptive name such as line, updating each corresponding
strip() usage while preserving behavior.
- Around line 803-807: Update the fallback handling around
_list_junit_files_fallback so _mark_errors_recovered only receives errors
generated by the initial recursive glob, not errors from fallback probes.
Preserve failed fallback errors so data_complete remains false when any fallback
step may have missing JUnit data, including the corresponding logic at the other
reported occurrence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 186beb90-97d7-4d9c-b942-a1c2253432a1

📥 Commits

Reviewing files that changed from the base of the PR and between 2b16d39 and 5a9351b.

📒 Files selected for processing (2)
  • plugins/ci/skills/payload-snapshot/SKILL.md
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • plugins/ci/skills/payload-snapshot/SKILL.md

Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py Outdated
Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py Outdated
Replaces the guidance to treat incomplete data as provisional. The agent
already has the job's gcs_url and the tooling to read it, so on a
collection failure it should analyze the job's artifacts directly rather
than reason around the gap.

Trimmed both additions to what the agent needs to do, dropping the
gcloud usage examples and step-by-step procedure.
…creds

The artifact buckets are public, but `gcloud storage` refuses client-side
when no account is configured — "You do not currently have an active
account selected" — without ever issuing the request. That is why an
unauthenticated environment produced a snapshot with zero JUnit XMLs and
zero build logs while every HTTPS-sourced artifact was fine.

Setting CLOUDSDK_AUTH_DISABLE_CREDENTIALS when no active account exists
makes gcloud read public objects anonymously, so the failure is now
prevented rather than merely reported. Resolved once under a lock, since
collectors run in a thread pool.

Verified with an empty CLOUDSDK_CONFIG (no credentials at all):
data_complete true, 73 blocking test failures, test_failure_count 68/31,
8 JUnit XMLs, 4 build logs, exit 0 — previously 0/0/15-empty.
Authenticated runs are unchanged.
@not-stbenjam not-stbenjam changed the title payload-snapshot: never report unreadable data as empty (supersedes #641) payload-snapshot: fix silent data loss on unauthenticated gcloud (supersedes #641) Jul 26, 2026
…very bounds

Four findings from review, all confirmed against the code and each
verified with a targeted run:

- Do not recover failed fallback probes. _mark_errors_recovered marked
  every error since the recursive glob as recovered, including failures
  the fallback itself hit while probing other step directories. Those are
  potentially missing data, not recovered data. The range is now bounded
  to the glob's own errors. Verified: glob fails + one probe fails ->
  4 recovered, 12 unrecovered, data_complete false, exit 1 (previously
  all 16 recovered and data_complete true).

- Partial JUnit reads no longer look authoritative. The guard only fired
  when nothing downloaded, so a job with two JUnit files where one failed
  published a count derived from half the data. Partial reads now record
  a junit_partial error and the job entry carries
  junit_collection_partial: true alongside test_failure_count, which is
  documented as a lower bound. Verified: count=66 partial=True where a
  complete read gives 68.

- Scope failure lookups by payload. The same job name recurs in every
  payload of the chain, so matching on job name alone against the global
  error list could stamp one payload's job entry with another payload's
  failure. Errors now carry payload_tag and _job_junit_state matches on it.

- Rename ambiguous `l` loop variables (Ruff E741) in the fallback.
  Reduces the file's ruff errors from 4 to 3; the remaining 3 are
  pre-existing on main and out of scope here.

Regression sweep, all unchanged or improved: authenticated (complete, 73
tests), unauthenticated (complete, 73), glob-fails-fallback-recovers
(complete, 73), all-reads-fail (incomplete, no results.json, exit 1).
@stbenjam

Copy link
Copy Markdown
Member

Deep Review Verdict

Disposition: REQUEST_CHANGES — five data-integrity failures are confirmed on current head 30043f9.

The unauthenticated-gcloud fix is directionally right, and the latest commit correctly addresses the earlier partial-download, payload-scoping, and bounded-fallback findings. However, the remaining paths below can still label missing or corrupt CI data as complete or as an authoritative zero.


Specialist Findings

Bugs / Adversarial

  1. BLOCKING — recovery bookkeeping is unsafe across concurrent collectors (payload_snapshot.py:814-833, 2418-2433)

    _COLLECTION_ERRORS is process-global, while collectors identify their errors by list indexes. Another worker can append an error between one collector's errors_before and glob_errors_end; a successful fallback then marks the other job's error recovered. The new upper bound protects against this collector's fallback-probe errors, but not cross-worker interleaving.

    Confirmed reproducer

    Steps: Two real JUnitCollector._list_junit_files() calls were synchronized with barriers. Collector B recorded a 403 and found no files; collector A then completed a successful fallback.

    Expected: Only A's recursive-glob error is recovered; B's 403 remains unrecovered.

    Actual: B's error changed to recovered: true; _unrecovered_errors() returned [].

  2. BLOCKING — --fail-on-incomplete succeeds when gcloud is missing (payload_snapshot.py:2902-2906, 2933-2962)

    The preflight disables JUnit/build-log/regression collection before _run_gcloud* can record gcloud_missing. The completeness gate therefore sees no errors.

    Confirmed reproducer

    Steps: Invoked main() with the real gcloud executable lookup forced to fail, default JUnit collection requested, and --fail-on-incomplete.

    Expected: gcloud_missing, data_complete: false, exit 1.

    Actual: no collection error, data_complete: true, exit 0.

  3. BLOCKING — no discovered JUnit is published as verified zero (payload_snapshot.py:824-836, 787-788)

    For a failed aggregate that legitimately returns matched no objects from every probe, discovery returns [] without an error and collect() writes results.json: []. This conflicts with the new payload-analysis rule that an aggregate with no per-test results is unclassified.

    Confirmed reproducer

    Expected: no authoritative count; mark the failed aggregate unclassified/unknown.

    Actual: results.json: [], test_failure_count: 0, data_complete: true, no collection errors.

  4. BLOCKING — malformed XML is published as verified zero (payload_snapshot.py:737, _parse_junit_xml around 2734-2739)

    _parse_junit_xml() swallows ET.ParseError and returns the same [] as valid XML with no failures. The collector has already counted the file as downloaded, so parsing corruption is invisible.

    Confirmed reproducer

    Steps: gcloud storage cat exited 0 with truncated JUnit containing a known <failure>.

    Expected: parse error recorded, incomplete snapshot, no authoritative count.

    Actual: parser returned []; results.json: [], test_failure_count: 0, data_complete: true.

  5. BLOCKING — rerunning a partial snapshot promotes it to complete (payload_snapshot.py:709-712, 1415-1421)

    Collection errors are process-local, but results.json persists. A fresh process skips any existing result file and regenerates summary.json from an empty error ledger.

    Confirmed reproducer

    Steps: First process collected one of two JUnit shards, producing junit_partial and data_complete: false. A fresh process reran against the same output directory.

    Expected: partial state remains persisted or the data is revalidated.

    Actual: collector skipped the existing results.json; the regenerated summary changed to data_complete: true, removed junit_collection_partial, and kept the lower-bound count as authoritative.

Security & Supply Chain

No injection, unsafe shell composition, dependency, or credential-use blocker found. Suggest sanitizing the raw 500-character gcloud stderr copied into shareable summary.json; auth diagnostics can expose account names, local config paths, or attacker-controlled control characters.

Architecture / Consistency

The positional global error ledger is the core structural issue. Returning a structured per-operation result, or assigning stable collector/operation IDs, would make recovery and completeness ownership explicit. A lock around append alone is insufficient unless it covers the entire glob/fallback transaction.

QA

There are no automated tests for the roughly 400 lines of snapshot/error-handling changes. Syntax compilation and git diff --check pass; repository pytest could not run here because pytest is unavailable. Please add regression tests for authenticated/anonymous/missing gcloud, complete/partial/absent/malformed JUnit, concurrent recovery, fallback probes, and resume behavior.

Technical Writer

The skill docs need to describe junit_partial, junit_collection_partial, and lower-bound count semantics; add --fail-on-incomplete to the CLI reference; and remove the contradictory instruction that gcloud authentication is required. The current statement that 0 is “verified clean” is not true until findings 3 and 4 are fixed.


Panel Synthesis

All functional reviewers converged on the same issue: completeness is inferred from a process-global error list rather than from explicit outcomes for each requested collection stage. Five runtime reproducers confirmed that this still permits silent promotion of missing, corrupt, concurrent, or previously-partial data. The latest commit's partial-read fixes are valid and were not re-raised.


Required Actions Before Merge

  1. Replace positional recovery of the global error list with operation/collector-owned error state.
  2. Record missing gcloud as incomplete when JUnit was requested, and make --fail-on-incomplete exit 1.
  3. Treat zero discovered JUnit and XML parse failures as unknown/incomplete, not results.json: [].
  4. Persist/reload collection state or revalidate existing result files on resume.
  5. Add automated regression coverage for the completeness contract.

Optional Follow-ups

  • Sanitize persisted gcloud diagnostics.
  • Avoid logging “data is complete” when recovered and unrecovered errors coexist.
  • Update the payload-snapshot and payload-analysis schema documentation for partial data.

Stats

Arbiter summary: 28 raw findings from 7 specialists. Kept: 5 blocking findings, all runtime-confirmed; 4 grouped suggestions/notes. Dropped: duplicates, findings fixed by 30043f9, and style-only noise.

Generated by /deep-review

…view

Five confirmed paths could still label missing or corrupt data as complete
or as an authoritative zero. All five are fixed, each with regression
coverage in the new test module.

1. Recovery bookkeeping was unsafe across concurrent collectors. Errors
   were identified by index into the process-global ledger, so another
   worker appending between one collector's start and end offsets could
   have its error marked recovered. Replaced with per-operation error
   scopes (thread-local stack): a caller now recovers the specific error
   objects its own calls produced. Global appends take a lock.

2. --fail-on-incomplete succeeded when gcloud was absent. Preflight
   disabled JUnit collection before any error could be recorded, so the
   gate saw an empty ledger. Missing gcloud with JUnit requested now
   records gcloud_missing.

3. Zero discovered JUnit was published as a verified zero. These
   collectors only run for jobs that failed, so "no JUnit anywhere" is
   unknown, not clean. Now records junit_missing and writes no
   results.json.

4. Malformed XML was published as a verified zero. _parse_junit_xml
   swallowed ET.ParseError and returned [] — indistinguishable from a
   valid file with no failures. It now returns None, and the collector
   records junit_unparseable and counts the file as unread.

5. Re-running a partial snapshot promoted it to complete. Errors were
   process-local while results.json persisted, and collectors skip
   existing output. The ledger is now persisted to collection_errors.json
   and, on startup, JUnit output a previous run recorded as incomplete is
   discarded so it is re-collected and re-judged.

Also: sanitize gcloud stderr before persisting it (strips control
characters, collapses whitespace, caps length) so shareable summaries
cannot carry terminal-spoofing sequences; stop logging "data is complete"
when recovered and unrecovered errors coexist; document junit_partial,
junit_missing, junit_unparseable, junit_collection_partial, lower-bound
count semantics, --fail-on-incomplete, and the resume behaviour; and
correct the prerequisite that claimed gcloud authentication is required.

Tests: 21 unit tests covering classification, no-match-is-not-an-error,
scoped and concurrent recovery, parse failure, all four publish
decisions, payload scoping, and resume invalidation. Verified end to end:
missing gcloud exits 1; a partial run followed by a healthy rerun
re-collects and reaches 68/31 complete rather than inheriting 66/29.
@not-stbenjam

Copy link
Copy Markdown
Contributor Author

All five blocking findings are fixed in 6993ef2. Each was confirmed against the code before changing anything, and each now has regression coverage. Thanks — findings 1, 3 and 4 in particular were real holes I would not have found without runtime reproducers.

1. Recovery bookkeeping across concurrent collectors — valid, and the underlying design was wrong, not just the bound. Errors were identified by index into the process-global ledger, so a concurrent worker appending between one collector's offsets could have its error recovered. Bounding the upper end (30043f9) only fixed the same-collector case, as you say.

Replaced positional ranges with per-operation error scopes: a thread-local stack of scopes, where every recorded error is appended to each active scope as well as the global ledger. _mark_errors_recovered() now takes the specific error objects a caller's own calls produced, so cross-worker interleaving is structurally impossible rather than narrowly avoided. Global appends take a lock. This is the "collector-owned error state" you asked for in required action 1.

2. --fail-on-incomplete with gcloud missing — valid. Preflight set collect_junit = False before any _run_gcloud* call could record gcloud_missing, so the gate saw an empty ledger. Now recorded at preflight. Verified end to end: PATH=/usr/bin:/bingcloud_missing, data_complete: false, exit 1.

3. Zero discovered JUnit published as verified zero — valid, and it contradicted the analysis-side rule in the same PR, which is a fair thing to catch. JUnitCollector is only constructed via _find_failed_jobs, so "no JUnit anywhere" is always an unknown, never a clean run. Now records junit_missing and writes no results.json.

4. Malformed XML published as verified zero — valid. _parse_junit_xml swallowed ET.ParseError and returned [], identical to a valid file with no failures. It now returns None (single caller, checked), and the collector records junit_unparseable and counts the file as unread — so corruption routes into the unavailable/partial paths instead of becoming a zero.

5. Rerun promotes a partial snapshot to complete — valid, and the most consequential for how this skill is actually used, since snapshot directories get reused. The ledger is now persisted to collection_errors.json, and on startup any JUnit output a previous run recorded as incomplete is discarded so it is re-collected and re-judged.

Verified end to end:

run 1 (one JUnit file fails):  complete=False counts=[66, 29] partial=[True, True]
run 2 (same dir, healthy):     re-collecting 4 job(s) whose previous JUnit collection was incomplete
                               complete=True  counts=[68, 31] partial=[None, None]

The rerun revalidates rather than inheriting — and reaching complete is legitimate here because the data was actually re-fetched.

Optional follow-ups, also done:

  • gcloud stderr is sanitized before persisting (control characters stripped, whitespace collapsed, capped at 300 chars), so a shareable summary.json cannot carry terminal-spoofing sequences.
  • No longer logs "data is complete" when recovered and unrecovered errors coexist.
  • Docs now cover junit_partial, junit_missing, junit_unparseable, junit_collection_partial, lower-bound count semantics, --fail-on-incomplete in the CLI reference, and the resume behaviour. The prerequisite claiming gcloud authentication is required is corrected — it is not, the buckets are public.
  • Your point that 0 was not truly "verified clean" was correct as written; with findings 3 and 4 fixed the statement now holds, and the docs state the distinction explicitly.

QA / required action 5 — added scripts/test_collection_completeness.py, 21 tests, discovered by make test via PLUGIN_TESTS. Coverage: stderr classification, no-match-is-not-an-error, scoped recovery, concurrent recovery under a barrier (the finding-1 reproducer), parse failure vs empty, all four publish decisions (complete / partial / unreadable / unparseable / none discovered), payload-scoped state, and resume invalidation including the recovered case. All 21 pass; ruff clean on the new file.

Two things I did not do, deliberately:

  • The three remaining ruff errors in payload_snapshot.py (two unused imports, one E741 around line 1058) are pre-existing on main and untouched by this PR, so I left them rather than widen the diff.
  • I kept the global ledger plus scopes rather than threading a structured result through every _run_gcloud call site. The scope mechanism gives per-operation ownership without touching ~20 call sites; a full structured-result refactor is worth doing but is a larger change than this fix warrants.

@stbenjam

Copy link
Copy Markdown
Member

/test payload-agent

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py (1)

1023-1035: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

build_log_unavailable uses a global counter delta, so it misattributes other workers' errors.

_collect_build_logs runs these collectors in a ThreadPoolExecutor. Between the two _unrecovered_error_count() reads, a different worker (build-log or JUnit) can append an error, so an empty build-log.txt here gets recorded as build_log_unavailable for this job even though its own read never failed. That's the same cross-thread attribution bug _error_scope was added to eliminate — use it here too.

🐛 Proposed fix: scope the error check to this call
         gcs_uri = f"gs://{self.job.gcs_bucket_path}/build-log.txt"
-        errors_before = _unrecovered_error_count()
-        raw = _run_gcloud_bytes(
-            ["gcloud", "storage", "cat", gcs_uri], timeout=120
-        )
+        with _error_scope() as own_errors:
+            raw = _run_gcloud_bytes(
+                ["gcloud", "storage", "cat", gcs_uri], timeout=120
+            )
         if not raw:
-            if _unrecovered_error_count() > errors_before:
+            if any(not e.get("recovered") for e in own_errors):
                 _record_collection_error(
                     "build_log_unavailable",
                     ["gcloud", "storage", "cat", gcs_uri],
                     detail="build-log.txt could not be read",
                     stage="build_log",
                     job=self.job.name,
                 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
1023 - 1035, Update the build-log read handling in _collect_build_logs to use
_error_scope around the _run_gcloud_bytes call instead of comparing the global
_unrecovered_error_count() before and after it. Record build_log_unavailable
only when that scoped call reports an error for the current job, preserving the
existing error details and stage.
🧹 Nitpick comments (1)
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py (1)

1529-1546: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider mentioning junit_collection_partial here too.

The block explains junit_collection_failed (absent count) but not the partial case, where test_failure_count is present but is a lower bound — the more likely misread of the two, since the number looks authoritative.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
1529 - 1546, The incomplete snapshot explanation in the summary-rendering block
should also document the `junit_collection_partial` case. Extend the guidance
near `junit_collection_failed` to state that when partial collection is
indicated, `test_failure_count` is present but only a lower bound and must not
be treated as authoritative.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/ci/skills/payload-analysis/SKILL.md`:
- Around line 310-314: Update Step 3.3 to treat candidates from
streak.originating_payload as preliminary only, then require candidate discovery
from each matching test_failures.blocking[] entry’s first_failed_in before
scoring. Ensure per-mode candidates are used when the onsets differ, with both
onsets recorded and the scoring source identified.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 2552-2566: The cleanup loop over previous entries must validate
persisted tag and job values before constructing any deletion path. Reject path
separators, traversal, and non-normalized components, then only call
shutil.rmtree for validated paths contained within base_dir; keep invalid
entries from affecting cleanup.
- Around line 1865-1868: Gate the _invalidate_suspect_junit call in the snapshot
collection flow on self.collect_junit, matching the existing JUnit re-collection
condition. When --no-junit is active, leave existing suspect JUnit data and
collection state untouched so the snapshot is not reported complete after
deleting data without replacement.
- Around line 2470-2481: Update _sanitize_detail to redact sensitive email-like
identifiers and remove or mask URL query strings before persisting the sanitized
detail, while preserving its existing control-character removal, whitespace
collapsing, and 300-character truncation behavior.

In `@plugins/ci/skills/payload-snapshot/SKILL.md`:
- Around line 214-220: Update the JUnit result-recording guidance around the
“junit_missing” and “junit_unparseable” cases so a parse failure omits
test_failure_count only when no JUnit XML was successfully parsed. Preserve
partial results.json and the lower-bound test_failure_count when at least one
JUnit file is readable, even if another file is malformed.
- Around line 190-192: Clarify the snapshot contract so data_complete is true
when all requested data is ultimately collected, even if an intermediate
collection failure was recovered by a fallback. Update the surrounding
collection_errors and completeness definitions consistently, preserving
unrecovered failures as the only reason for data_complete: false.

---

Outside diff comments:
In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 1023-1035: Update the build-log read handling in
_collect_build_logs to use _error_scope around the _run_gcloud_bytes call
instead of comparing the global _unrecovered_error_count() before and after it.
Record build_log_unavailable only when that scoped call reports an error for the
current job, preserving the existing error details and stage.

---

Nitpick comments:
In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 1529-1546: The incomplete snapshot explanation in the
summary-rendering block should also document the `junit_collection_partial`
case. Extend the guidance near `junit_collection_failed` to state that when
partial collection is indicated, `test_failure_count` is present but only a
lower bound and must not be treated as authoritative.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 89d183d2-4bd3-4c70-b4f1-de0b68a7fcdd

📥 Commits

Reviewing files that changed from the base of the PR and between 5a9351b and 6993ef2.

📒 Files selected for processing (4)
  • plugins/ci/skills/payload-analysis/SKILL.md
  • plugins/ci/skills/payload-snapshot/SKILL.md
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
  • plugins/ci/skills/payload-snapshot/scripts/test_collection_completeness.py

Comment thread plugins/ci/skills/payload-analysis/SKILL.md
Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py Outdated
Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
Comment thread plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
Comment thread plugins/ci/skills/payload-snapshot/SKILL.md
Comment thread plugins/ci/skills/payload-snapshot/SKILL.md Outdated
…nostics

Six further review findings, all confirmed.

--no-junit over an existing directory was destructive. Invalidation ran
unconditionally while re-collection is gated on collect_junit, so the run
deleted the suspect junit/ dirs, replaced nothing, and rewrote the ledger
from an empty in-memory list — reporting data_complete: true for a
snapshot it had just stripped. Gating the delete alone is not enough: the
ledger would still be dropped and the stale partial output still counted.
Invalidation now runs only when JUnit will be re-collected, and otherwise
the unresolved JUnit errors are carried forward so the snapshot stays
honestly incomplete. Verified: run 1 partial (4 XMLs, 66/29, incomplete),
run 2 with --no-junit keeps all 4 XMLs, stays incomplete, exits 1.

The recovery ledger is data read back from disk, so it must not be able to
aim a recursive delete. Path components are now rejected unless they are
safe single components, and the resolved target must be contained within
the snapshot directory.

Persisted diagnostics are redacted, not just stripped of control
characters: gcloud auth errors routinely name the active account and URLs
can carry signed-request parameters, and summary.json exists to be handed
to agents and shared.

Docs: define data_complete as all requested data being ultimately
collected (a recovered failure is not incompleteness); scope the "no
count" rule to JUnit that was entirely unreadable, since a parse failure
alongside readable files is the partial case; and mark Step 3.3's
job-level candidate list as preliminary, requiring per-mode candidates
from first_failed_in before scoring — otherwise the causal PR can be
excluded from the candidate set entirely.

Tests: 25 total, adding coverage for unsafe ledger entries (no delete
outside base), carry-forward on --no-junit, and identifier redaction.
@not-stbenjam

Copy link
Copy Markdown
Contributor Author

/test payload-agent

@stbenjam

Copy link
Copy Markdown
Member

/ok-to-test

@not-stbenjam

Copy link
Copy Markdown
Contributor Author

Verified in CI — artifact collection is fixed

payload-agent run on a1d9138 passed, analyzing 5.0.0-0.nightly-2026-07-26-021553. The release controller had already analyzed the same payload with main, so this is a controlled comparison: same payload, same 10-payload chain, same 79 failed jobs, same CI image.

main (RC run) this PR (run)
Fetching JUnit for 79 failed jobs 79 failed jobs
Failing tests found 0: 0 new, 0 persistent 112: 2 new, 110 persistent
JUnit XMLs in snapshot 0 populated
build_log.json 0 populated
results.json 63 files, all empty populated
test_failure_count per failed job 0, 0, 0 real counts
data_complete key absent present

The main numbers are from its own uploaded snapshot-*.tar, not inferred.

Root cause confirmed in the real environment. The CI container has no gcloud credentials, and gcloud storage refuses client-side rather than reading the public buckets:

Note: 'gcloud' has no active credentials. CI artifact buckets
are public, so they will be read anonymously.
  note: no gcloud credentials found — reading public artifact buckets anonymously

The main run never mentions credentials at all — it had no idea it was failing, and reported test_failure_count: 0 for three jobs whose tests it simply could not read.

The analysis improved too. With per-test onset data available, the agent caught the exact error class that motivated this PR:

So payload 5 actually failed at the assembly stage rather than in tests, which means the test didn't even run — the job-level failure is for a different reason entirely.

The UDN tests first failed in payload 7 with 0 PRs, but since they were passing in payload 6 with 30 PRs, those 30 PRs are the real candidates.

That is job-level streak vs per-test onset being distinguished inline — previously impossible, because there was no per-test data at all.

@neisw

neisw commented Jul 26, 2026

Copy link
Copy Markdown

/lgtm

@openshift-ci openshift-ci Bot added the lgtm Indicates that a PR is ready to be merged. label Jul 26, 2026
@stbenjam

Copy link
Copy Markdown
Member

Extending self-approval rights to my bot

/approve

@openshift-ci

openshift-ci Bot commented Jul 26, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: neisw, not-stbenjam, stbenjam

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci openshift-ci Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Jul 26, 2026
@openshift-merge-bot
openshift-merge-bot Bot merged commit 445ecbe into openshift-eng:main Jul 26, 2026
7 checks passed
not-stbenjam added a commit to not-stbenjam/ai-helpers that referenced this pull request Jul 28, 2026
… failures

A test result falls into one of three categories, and only one can fail a
job and therefore reject a payload:

  flake     - same test, same suite, both failed and passed  -> does not gate
  informing - testcase carries lifecycle="informing"          -> does not gate
  failure   - failed everywhere, no informing lifecycle       -> gates

The parser recognised neither. `grep -ci flake` was 0, and the testcase's
`lifecycle` attribute was read and discarded, so `_test_results_to_json`
emitted every `<failure>` element as a failure. That overstated what could
have rejected a payload and, worse, let a non-cause drive regression onset:
`first_failed_in` could be set by an informing test, sending analysis to
hunt for a culprit PR behind a test that never gated anything.

Real example from the payload chain analysed in openshift-eng#644: one conformance run's
JUnit contains 11 failures, *all* of them informing UDN tests being
stabilized — 0 gating. It previously reported test_failure_count: 11.

Changes:
- `_TestResult` carries `test_lifecycle` from the testcase attribute. A
  missing attribute means the test gates; the attribute exists only to opt
  out.
- `_mark_flakes()` relabels a failure as a flake when the same test also
  passed in the same suite. Grouping is per (suite, name): the same monitor
  evaluated in `openshift-tests-upgrade` and `openshift-tests` covers two
  different phases, and a pass in one does not clear a failure in the other.
- `results.json` records all three categories with `status` and
  `test_lifecycle`, so nothing is hidden.
- `test_failure_count` counts gating results only; `test_flake_count` and
  `test_informing_failure_count` are reported separately.
- `summary.json` gains `test_failures.informing[]` and
  `test_failures.flakes[]`. No onset is tracked for either — an onset
  implies a culprit to find.
- Regression tracking considers gating failures only, and logs what it
  excluded.
- payload-analysis: never score informing failures or flakes as candidates,
  never derive an originating payload from them, never revert for them;
  report them in their own section stating they cannot cause a rejection,
  with the one exception worth investigating — a test that harms the
  cluster it runs on.

Tests: 13 new covering flake detection (including the cross-suite case that
must NOT be treated as a flake), informing classification, absent-lifecycle
gating, and mixed counting; validated against real aggregated and
non-aggregated JUnit.
stbenjam pushed a commit that referenced this pull request Jul 29, 2026
…ately from failures (#645)

* payload-snapshot: classify flakes and informing tests separately from failures

A test result falls into one of three categories, and only one can fail a
job and therefore reject a payload:

  flake     - same test, same suite, both failed and passed  -> does not gate
  informing - testcase carries lifecycle="informing"          -> does not gate
  failure   - failed everywhere, no informing lifecycle       -> gates

The parser recognised neither. `grep -ci flake` was 0, and the testcase's
`lifecycle` attribute was read and discarded, so `_test_results_to_json`
emitted every `<failure>` element as a failure. That overstated what could
have rejected a payload and, worse, let a non-cause drive regression onset:
`first_failed_in` could be set by an informing test, sending analysis to
hunt for a culprit PR behind a test that never gated anything.

Real example from the payload chain analysed in #644: one conformance run's
JUnit contains 11 failures, *all* of them informing UDN tests being
stabilized — 0 gating. It previously reported test_failure_count: 11.

Changes:
- `_TestResult` carries `test_lifecycle` from the testcase attribute. A
  missing attribute means the test gates; the attribute exists only to opt
  out.
- `_mark_flakes()` relabels a failure as a flake when the same test also
  passed in the same suite. Grouping is per (suite, name): the same monitor
  evaluated in `openshift-tests-upgrade` and `openshift-tests` covers two
  different phases, and a pass in one does not clear a failure in the other.
- `results.json` records all three categories with `status` and
  `test_lifecycle`, so nothing is hidden.
- `test_failure_count` counts gating results only; `test_flake_count` and
  `test_informing_failure_count` are reported separately.
- `summary.json` gains `test_failures.informing[]` and
  `test_failures.flakes[]`. No onset is tracked for either — an onset
  implies a culprit to find.
- Regression tracking considers gating failures only, and logs what it
  excluded.
- payload-analysis: never score informing failures or flakes as candidates,
  never derive an originating payload from them, never revert for them;
  report them in their own section stating they cannot cause a rejection,
  with the one exception worth investigating — a test that harms the
  cluster it runs on.

Tests: 13 new covering flake detection (including the cross-suite case that
must NOT be treated as a flake), informing classification, absent-lifecycle
gating, and mixed counting; validated against real aggregated and
non-aggregated JUnit.

* payload-snapshot: drop the flake and informing count fields

Per-job entries carry a single failure count again. test_flake_count and
test_informing_failure_count are removed: neither is a failure count, and
having three numbers next to each other invites summing them back into the
inflated total this change exists to remove.

test_failure_count remains the gating count. The non-gating results are not
lost — every one is still recorded by name in the job's results.json with
its status and test_lifecycle, and listed under test_failures.flakes[] and
test_failures.informing[] in the summary.

* Address review: fix thread-safety, classification gaps, and stale regressions

- Use _error_scope() in BuildLogCollector to avoid racing the global
  error count across concurrent worker threads
- Extend junit_collection_failed to cover junit_missing and
  junit_unparseable, not just junit_unavailable
- Remove stale regressions.json when JUnit is invalidated so
  _track_regressions recomputes from fresh data
- Narrow _GCLOUD_NO_MATCH_PATTERNS by removing overly broad "not found"
  and check auth patterns before no-match to prevent auth errors from
  being silently classified as benign

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump ci plugin version to 0.0.75 and regenerate docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump ci plugin version to 0.0.77

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. lgtm Indicates that a PR is ready to be merged. ok-to-test Indicates a non-member PR verified by an org member that is safe to test.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants