Skip to content

fix: report only genuinely current CI state - #2

Merged
fredclausen merged 3 commits into
mainfrom
fix/ci-state-accuracy
Aug 10, 2026
Merged

fix: report only genuinely current CI state#2
fredclausen merged 3 commits into
mainfrom
fix/ci-state-accuracy

Conversation

@fredclausen

@fredclausen fredclausen commented Aug 10, 2026

Copy link
Copy Markdown
Member

Naively taking the newest run per workflow produced 38 "failures" across sdr-enthusiasts and fredsystems, of which fewer than half described anything actionable.

Four distinct causes, each verified against the live API:

Cause Effect Fix
Orphaned runs GitHub keeps run history after a workflow file is deleted. A long-removed Update pre-commit hooks reported a permanent failure in 10 repos. Intersect runs with the live workflow list.
Path-named runs A run created before its workflow gained a name: reports the file path instead, splitting one workflow into two series. Key identity on path; resolve display name from the live list.
Pull-request runs The API's branch= filter matches a PR's head branch, not its base. A merged PR's last pre-merge failure was reported as the branch's CI state indefinitely. Count only push, schedule, workflow_dispatch.
Stale runs Workflows firing only on pull_request leave a branch-state run months old — pre-commit-checks Lint last ran on a push in Dec 2025. Past 90 days, report conclusion="stale" and set workflow_run_stale.

Disabled workflows are no longer filtered out. Excluding them hid a failing update-flakes in fred-cal that GitHub had auto-disabled for repository inactivity — precisely the condition worth alerting on. workflow_enabled now carries the state, distinguishing an inactivity auto-disable from a deliberate manual one.

Result

Series Failures
Before 209 38
After 126 16 real + 7 aged out

Testing

  • 73 unit tests (11 new), covering each filter and the staleness boundary
  • cargo xtask ci green: fmt, clippy (strict lint set), test, deny, machete
  • All 28 pre-commit hooks pass
  • Verified end-to-end against the live API with a public_repo read token

Summary by CodeRabbit

  • New Features

    • Added metrics showing whether workflows are enabled, including active and disabled states.
    • Added metrics identifying workflow runs that have been stale for 90 days or more.
    • Improved workflow and run tracking to reflect current definitions and branch-state events.
  • Bug Fixes

    • Excluded deleted, dynamic, orphaned, or pathless workflows and runs from reported results.
    • Preserved monitoring data when workflow listing temporarily fails.
    • Retained disabled workflows in reported monitoring data.
  • Documentation

    • Documented the new metrics and current CI state behavior.

Naively taking the newest run per workflow produced 38 "failures" across
the two organisations, of which fewer than half described anything
actionable. Four distinct causes, each verified against the live API:

- Orphaned runs. GitHub retains run history after a workflow file is
  deleted, so a long-removed "Update pre-commit hooks" reported a
  permanent failure in 10 repositories. Runs are now intersected with
  the repository's live workflow list.

- Path-named runs. A run created before its workflow gained a `name:`
  reports the file path in the name field, splitting one workflow into
  two series. Workflow identity is now the file path, with the display
  name resolved from the live list.

- Pull-request runs. The API's `branch=` filter matches a pull
  request's HEAD branch, not its base, so PR runs leaked into what is
  meant to be default-branch state. A merged PR's last pre-merge
  failure was reported as the branch's CI state indefinitely. Only
  push, schedule, and workflow_dispatch now count.

- Stale runs. Workflows that fire only on pull_request leave a
  branch-state run months old; `pre-commit-checks` Lint last ran on a
  push in December 2025. Past 90 days a run reports
  conclusion="stale" and sets `workflow_run_stale`, rather than an
  unclearable failure.

Disabled workflows are no longer filtered out. Excluding them hid a
failing `update-flakes` in fred-cal that GitHub had auto-disabled for
repository inactivity, which is precisely the condition worth alerting
on. `workflow_enabled` now carries the state, distinguishing an
inactivity auto-disable from a deliberate manual one.

Net effect: 209 workflow series to 126, and 38 failures to 16 real
plus 7 aged out.
Copilot AI lite review requested due to automatic review settings August 10, 2026 03:15
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d6829805-16c8-4ef1-bf8f-9c79fb4aac47

📥 Commits

Reviewing files that changed from the base of the PR and between 9634586 and 3c406d6.

📒 Files selected for processing (1)
  • github-ci-exporter/src/github/client.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • github-ci-exporter/src/github/client.rs

📝 Walkthrough

Walkthrough

The exporter now caches complete workflow definitions, tracks enabled state, filters runs by live workflow paths and branch-state events, marks runs older than 90 days as stale, and exposes metrics and documentation for these states.

Changes

Workflow State Observability

Layer / File(s) Summary
Workflow and run contracts
github-ci-exporter/src/github/rest.rs
Workflow definitions now include mapped states. Run reduction uses live workflow paths, supported events, cache fingerprints, and stale-run detection.
Workflow resolution and collection
github-ci-exporter/src/collector.rs
Repository monitoring caches workflow definitions, preserves repositories when listing fails, publishes workflow states, and records stale runs.
Cache projection isolation
github-ci-exporter/src/github/client.rs
Cached projections can use explicit keys that are separate from request paths. ETag lookup, replay, and invalid-entry removal use the selected key.
Metric family registration
github-ci-exporter/src/metrics.rs
The exporter registers and clears workflow_enabled and workflow_run_stale metric families.
Regression coverage and documentation
github-ci-exporter/src/github/rest.rs, README.md
Tests cover workflow filtering, identity, disabled states, path handling, cache invalidation, and staleness. The README documents the new metrics and CI state rules.

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

Sequence Diagram(s)

sequenceDiagram
  participant Collector
  participant GitHubAPI
  participant RunReducer
  participant Metrics
  Collector->>GitHubAPI: list workflow definitions
  GitHubAPI-->>Collector: return live and disabled workflows
  Collector->>GitHubAPI: fetch workflow runs
  GitHubAPI-->>RunReducer: return run entries
  RunReducer->>RunReducer: filter events and match workflow paths
  RunReducer->>Metrics: report conclusions and stale status
  Collector->>Metrics: publish workflow enabled state
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: reporting only current CI workflow state.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-state-accuracy

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
README.md (1)

119-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the values of the state label.

The section explains why disabled workflows are kept, but it does not list the values github_workflow_enabled can carry. An operator writing an alert needs to know they are active, disabled_inactivity, and disabled_manually, and that only disabled_inactivity indicates a fault.

📝 Proposed addition
 | Disabled workflows are kept   | A workflow auto-disabled by GitHub after 60 days of inactivity has stopped running silently. That is the fault worth alerting on, so it is reported rather than filtered out.                          |
+
+`github_workflow_enabled` is `1` when GitHub will run the workflow. Its
+`state` label is `active`, `disabled_inactivity`, or `disabled_manually`.
+Only `disabled_inactivity` indicates a fault; a manual disable is deliberate.
🤖 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 `@README.md` around lines 119 - 131, Update the “What counts as the current CI
state” section to document the possible github_workflow_enabled state values:
active, disabled_inactivity, and disabled_manually. Clarify that only
disabled_inactivity represents a fault requiring alerting.
github-ci-exporter/src/collector.rs (1)

417-447: 🩺 Stability & Availability | 🔵 Trivial

Confirm that alert rules are updated for the new conclusion="stale" value.

A stale run no longer reports conclusion="failure". Any recording rule or alert that selects github_workflow_run_status{conclusion="failure"} silently stops matching those 7 runs. Update the rules together with this change, or the transition looks like the failures were fixed.

🤖 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 `@github-ci-exporter/src/collector.rs` around lines 417 - 447, Update the
recording and alert rules that select github_workflow_run_status with
conclusion="failure" to also account for conclusion="stale", preserving failure
alerts while ensuring stale runs remain represented in the alerting behavior
introduced by the status assignment above.
🤖 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 `@github-ci-exporter/src/collector.rs`:
- Around line 166-177: Update the repository loop around
`record_workflow_states` and `rest::fetch_runs` to skip fetching runs when
`live` is empty, and emit a warning explaining that the run fetch was skipped
because no cached workflows are available. Preserve the existing fetch and
error-handling flow for repositories with non-empty `live` data.

In `@github-ci-exporter/src/github/rest.rs`:
- Around line 241-263: Use workflow path as the identity throughout reduce_runs:
key latest and last_success by path, while retaining the display name inside
LatestRun for presentation. In github-ci-exporter/src/collector.rs lines
310-327, update the workflow_enabled labels to include a path-based
disambiguator so workflows with duplicate names remain distinct; both sites
require changes.
- Around line 186-199: Update fetch_runs and its get_cached usage so cache
entries vary with the current live workflow paths and names, preventing stale
RepoRuns after deletions or renames. Either incorporate a stable fingerprint of
live into the cache key while preserving URL-based request behavior, or cache
raw workflow runs and reapply reduce_runs on each fetch.

---

Nitpick comments:
In `@github-ci-exporter/src/collector.rs`:
- Around line 417-447: Update the recording and alert rules that select
github_workflow_run_status with conclusion="failure" to also account for
conclusion="stale", preserving failure alerts while ensuring stale runs remain
represented in the alerting behavior introduced by the status assignment above.

In `@README.md`:
- Around line 119-131: Update the “What counts as the current CI state” section
to document the possible github_workflow_enabled state values: active,
disabled_inactivity, and disabled_manually. Clarify that only
disabled_inactivity represents a fault requiring alerting.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb16dc6d-bddc-41ca-bb20-4ab7bedc1863

📥 Commits

Reviewing files that changed from the base of the PR and between 36fd88b and 990548f.

📒 Files selected for processing (4)
  • README.md
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/github/rest.rs
  • github-ci-exporter/src/metrics.rs

Comment thread github-ci-exporter/src/collector.rs
Comment thread github-ci-exporter/src/github/rest.rs
Comment on lines +241 to +263
fn reduce_runs(runs: Vec<RunEntry>, live: &[Workflow]) -> RepoRuns {
// Workflow identity is the file path, not the display name: a run created
// before the workflow gained a `name:` reports the path in the name field,
// and a renamed workflow would otherwise split into two series.
let live_by_path: HashMap<&str, &Workflow> =
live.iter().map(|w| (w.path.as_str(), w)).collect();

let mut latest: HashMap<String, LatestRun> = HashMap::new();
let mut last_success: HashMap<String, DateTime<Utc>> = HashMap::new();

for run in runs {
if run.event == "dynamic" {
if !is_branch_state_event(&run.event) {
continue;
}
// A run whose workflow file was deleted still appears in history;
// without a path there is no stable identity to key on.
let Some(name) = run.name.filter(|n| !n.is_empty()) else {
// Runs of a since-deleted workflow linger in history forever. Only
// workflows still present in the repository are reported.
let Some(path) = run.path.as_deref() else {
continue;
};
if run
.path
.as_ref()
.is_some_and(|p| !p.starts_with(".github/workflows"))
{
let Some(workflow) = live_by_path.get(path) else {
continue;
}
};
let name = workflow.name.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Workflow identity is the file path in the lookup but the display name in every key and label. The PR establishes the path as workflow identity, yet both the reduction maps and the metric labels use name. Two workflow files that declare the same name: therefore merge into one series in both places.

  • github-ci-exporter/src/github/rest.rs#L241-L263: key latest and last_success by workflow path, and carry the display name inside LatestRun.
  • github-ci-exporter/src/collector.rs#L310-L327: add a disambiguator to the workflow_enabled labels, or document that duplicate workflow names collapse.
📍 Affects 2 files
  • github-ci-exporter/src/github/rest.rs#L241-L263 (this comment)
  • github-ci-exporter/src/collector.rs#L310-L327
🤖 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 `@github-ci-exporter/src/github/rest.rs` around lines 241 - 263, Use workflow
path as the identity throughout reduce_runs: key latest and last_success by
path, while retaining the display name inside LatestRun for presentation. In
github-ci-exporter/src/collector.rs lines 310-327, update the workflow_enabled
labels to include a path-based disambiguator so workflows with duplicate names
remain distinct; both sites require changes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR improves the accuracy of the exporter’s “current CI state” reporting by ensuring only relevant, genuinely current GitHub Actions data contributes to branch health, while still surfacing important non-running conditions (like inactivity auto-disables).

Changes:

  • Filter workflow runs to branch-state events, intersect run history with the live workflow list, and key run identity off workflow path (with display name resolved from the live list).
  • Add staleness handling (90-day horizon) so ancient branch-state runs report conclusion="stale" and emit a dedicated github_workflow_run_stale metric.
  • Stop filtering out disabled workflows; expose workflow enablement state via a new github_workflow_enabled metric (including state).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
README.md Documents the new metrics and defines what counts as “current CI state.”
github-ci-exporter/src/metrics.rs Adds metric families and registrations for workflow enablement and staleness.
github-ci-exporter/src/github/rest.rs Updates workflow/run fetching and reduction logic to eliminate orphan/PR/stale noise and normalize identity by workflow path.
github-ci-exporter/src/collector.rs Caches live workflow sets, records workflow enabled state, and publishes stale-run signals/labels during run recording.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +292 to +302
if workflows.is_empty() && config.skip_repos_without_workflows {
cache.workflows.remove(&key);
skipped.push((repo, SkipReason::NoWorkflows));
continue;
}
cache.workflows.insert(key.clone(), workflows.clone());

// Cron schedules change rarely, so they are resolved once per set.
if let std::collections::hash_map::Entry::Vacant(entry) = cache.intervals.entry(key) {
entry.insert(resolve_cron_intervals(client, &repo, &workflows).await);
}
Comment on lines +242 to +246
// Workflow identity is the file path, not the display name: a run created
// before the workflow gained a `name:` reports the path in the name field,
// and a renamed workflow would otherwise split into two series.
let live_by_path: HashMap<&str, &Workflow> =
live.iter().map(|w| (w.path.as_str(), w)).collect();
Three issues raised in review, all confirmed against the code:

- The projection cache could revive the orphaned-run bug. `get_cached`
  keys on the request URL and replays the stored reduction on 304, but
  the reduction also depends on the live workflow list. Deleting or
  renaming a workflow leaves the runs listing unchanged, so the request
  answers 304 and the stale reduction is served indefinitely. Cache
  entries for runs now include an order-independent fingerprint of the
  workflow set's paths and names, via a new `get_cached_as` that
  decouples the cache key from the request path. Warm-cache reuse is
  unaffected: 310 of 379 requests still answer 304.

- An empty workflow set silently emptied a repository. When
  `list_workflows` fails and nothing is cached, every run is discarded
  as orphaned, so the repository publishes no series at all while still
  spending a request. That is the "CI vanished" appearance the budget
  pre-flight exists to avoid. The run fetch is now skipped with a
  warning.

- Documented that lookup is by path while output is keyed by display
  name, so two workflow files sharing a `name:` collapse into one
  series. Accepted deliberately: it does not occur in the monitored
  organisations, and keying metrics by file path would make dashboards
  and alerts harder to read.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@github-ci-exporter/src/github/client.rs`:
- Around line 407-417: Update the cache insertion in the request flow to store
entries under the computed cache_key, matching the lookup used by get_cached_as
and custom workflow-fingerprint requests. Extend the existing cache test around
the repeated `#v1` request to issue a second request with the cached ETag, assert
If-None-Match is sent, and verify the result is CacheOutcome::NotModified.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 48bedf1e-4537-4c9c-b925-2fdd86628605

📥 Commits

Reviewing files that changed from the base of the PR and between 990548f and 9634586.

📒 Files selected for processing (3)
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/github/client.rs
  • github-ci-exporter/src/github/rest.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • github-ci-exporter/src/github/rest.rs

Comment thread github-ci-exporter/src/github/client.rs
Entries were read by `cache_key` but written by `url`, so every
`get_cached_as` caller stored under a key it would never look up. Since
`fetch_runs` always uses a workflow-fingerprint key, the runs endpoint --
the largest consumer of the rate-limit budget -- refetched and
reprojected on every cycle instead of revalidating.

The regression was masked in testing: the warm-cache run still showed
310 of 379 requests answered 304, because the workflow-list and
file-contents lookups were cached normally. Only the 62 run fetches were
affected. With the write corrected, warm cycles answer 372 of 379 from
cache.

Adds a test asserting that a repeated custom key revalidates and yields
`CacheOutcome::NotModified`, which the previous test did not cover: it
checked only that two *different* keys stay separate.
@fredclausen
fredclausen merged commit 1368f48 into main Aug 10, 2026
8 checks passed
@fredclausen
fredclausen deleted the fix/ci-state-accuracy branch August 10, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants