Skip to content

fix(scm): derive CI evidence from Actions when the check rollup is unreadable - #815

Open
quinnbot-ai wants to merge 1 commit into
kunchenguid:mainfrom
quinnbot-ai:fm/nm690-actions-fallback-pr
Open

fix(scm): derive CI evidence from Actions when the check rollup is unreadable#815
quinnbot-ai wants to merge 1 commit into
kunchenguid:mainfrom
quinnbot-ai:fm/nm690-actions-fallback-pr

Conversation

@quinnbot-ai

Copy link
Copy Markdown

Implements #690.

Problem

internal/scm/github.Host.GetChecks reads the GraphQL statusCheckRollup on the exact PR head commit, which is served by the Checks API. Some credentials — notably fine-grained tokens — are refused that context with a 403 while still being allowed to read the same repository's Actions workflow runs and jobs for the same commit.

Today that failure returns immediately. getWorkflowRunChecks (#616) is a supplement on the success path, so it is never reached. The CI step then logs warning: could not check CI: ... on every poll and keeps polling until ci_timeout, so a credential problem becomes an hours-long silent stall whose only artifact is a repeated warning.

Behavior

internal/scm/github/actions_fallback.go (new) adds a fallback, and the CI step bounds evidence-free polling.

  • The rollup stays the only primary source and is never second-guessed when it answers. A primary read failure is classified (rollupReadError / classifyRollupUnavailable); only "this credential may not read the rollup" evidence — Resource not accessible, insufficient scopes, HTTP 403, a statusCheckRollup field error — opens the fallback. Every other failure (502, rate limit, malformed response) surfaces exactly as before.
  • The fallback binds every read to the configured repository, the PR, and the currently published head SHA: workflow runs are listed by head_sha with pagination, each relevant run's jobs are listed with pagination (filter=latest), and the PR head is re-read afterwards through the same assertHeadUnchanged guard the primary path uses.
  • Job-level results become scm.Checks with the job's name, bucket, provider state, completion time, and job link — the same link shape RerunCheck already parses, so transient-rerun targeting keeps working on the fallback path.
  • Green is certified only when the base branch's required-check definition is readable and every required identity has exactly one exact-current-head Actions mapping, all of them completed with success (or skipped). Queued and in-progress stay pending; terminal non-success is non-green.
  • internal/pipeline/steps/ci.go counts consecutive polls that produced no evidence at all. After maxConsecutiveCheckReadFailures (5) it parks with a typed ask-user outcome naming the last provider failure instead of waiting for ci_timeout. A successful read resets the budget, so ordinary transient failures are absorbed.

Typed outcomes, all wrapping the new scm.ErrChecksUnavailable so the step layer can classify without importing the provider: ErrRollupUnavailable, ErrActionsEvidenceMissing, ErrActionsEvidenceAmbiguous, ErrActionsHeadMismatch (also wraps scm.ErrHeadChanged), ErrActionsAPIFailure.

Fail-closed guarantees

An ambiguous Actions listing never becomes a pass. Everything below stays unavailable evidence rather than green:

  • A workflow run whose head_sha is not the head under test.
  • A job from any attempt other than the run's current one — a superseded rerun's green leftover cannot certify a commit whose current attempt has not finished.
  • A run or job listing whose pages disagree with total_count, contain a duplicate id, or contain an id-less entry. A short listing is the one way a failing job could disappear into a pass, so pagination is validated rather than trusted (the run listing already did this; the job listing now does the same).
  • A required check with no Actions result at this head, or with more than one — the routine shape when a workflow runs for both the push and pull_request event, which is exactly where a naive matcher would invent a pass.
  • An unreadable required-check definition, or a base branch with none. Actions cannot see check runs published by other GitHub Apps, so the required-check set is the only thing that bounds what the Actions view cannot see; without it the fallback reports and never certifies.
  • A run that claims success while exposing no readable job. (A skipped jobless run is reported as its own skip result — that is a real conclusion, not an absence.)
  • A completed job whose conclusion this version cannot classify, and any non-terminal status: both stay pending, never pass.

Non-green evidence needs none of this certification and is returned as-is, because failing and still-running results can only make the pipeline wait or escalate. That is deliberate: a genuine Actions failure is still surfaced (and auto-fixable) even when the required-check definition is unreadable.

Coverage

internal/scm/github/actions_fallback_test.go:

  • TestGetChecksRollupFailureWithoutCapabilityEvidenceDoesNotFallBack — existing primary path is unchanged for non-capability failures
  • TestGetChecksFallsBackToActionsJobsAtExactHead — successful exact-head fallback, incl. job link and completion time
  • TestGetChecksFallbackPendingJobStaysPending, TestGetChecksFallbackFailedJobIsNonGreen
  • TestGetChecksFallbackRequiredCheckWithoutMappingStaysUnavailable, TestGetChecksFallbackAmbiguousMappingNeverPasses
  • TestGetChecksFallbackRejectsRunOnAnotherCommit, TestGetChecksFallbackRejectsSupersededAttemptJob
  • TestGetChecksFallbackFollowsRunAndJobPagination, TestGetChecksFallbackRejectsIncompleteJobPagination
  • TestGetChecksFallbackUnreadableRequiredChecksStaysUnavailable, TestGetChecksFallbackUnprotectedBranchNeverCertifies
  • TestGetChecksFallbackActionsAPIFailureIsUnavailableEvidence, TestGetChecksFallbackWithoutAnyRunIsMissingEvidence
  • TestGetChecksFallbackJoblessSuccessfulRunIsNotEvidence, TestGetChecksFallbackJoblessSkippedRunReportsTheRun
  • TestGetChecksFallbackUsesTheRecordedPRBaseBranch, TestClassifyRollupUnavailable

internal/pipeline/steps/ci_test.go:

  • TestCIStep_RepeatedEvidenceFailuresParkForADecision — bounded API failure handling
  • TestCIStep_SuccessfulReadResetsTheEvidenceFailureBudget — an intermittent failure never accumulates into a park

gofmt, make lint, and go test -race ./... are green.

Note on the reviewer's code map

The triage comment inspected 8d6ebbf9; this branch is cut from 4a5cec6. The map still holds — GetChecks / getCommitChecks / getWorkflowRunChecks and the CI monitor loop are where the work landed, with the head-SHA assignment now at ci.go:359 after the intervening commits. getWorkflowRunChecks keeps its supplement-on-success role untouched; its listing/pagination validation was extracted to listWorkflowRunsForHead so the fallback reuses one owner of that check instead of writing a second copy.

Deliberately out of scope

  • Non-Actions commit statuses are not read. Where one is a required check, it has no Actions mapping and the fallback stays unavailable rather than certifying around it.
  • Non-GitHub backends are untouched; scm.ErrChecksUnavailable is available to them but nothing else adopts it here.

GetChecks reads the GraphQL statusCheckRollup, which is served by the
Checks API. Some credentials - notably fine-grained tokens - are refused
that context with a 403 while still being allowed to read the same
repository's Actions workflow runs and jobs for the same commit. That
failure returned immediately, so the Actions supplement added in kunchenguid#616 was
never reached, and the CI step logged the same warning on every poll until
ci_timeout with nothing actionable to show for it.

Classify the primary failure and, only when it is capability evidence that
the rollup itself is unreadable, derive job-level evidence from the Actions
REST API bound to the exact repository, PR, and published head SHA.

Nothing here turns doubt into a pass. Actions cannot see check runs
published by other GitHub Apps, so a green Actions set is certified only
when the base branch's required-check definition is readable and every
required identity has exactly one exact-current-head mapping from the
latest attempt. A run on another commit, a job from a superseded attempt,
an incomplete listing, a required check with no unique mapping, an
unprotected base branch, an unreadable required-check definition, and a
successful run exposing no job all stay unavailable evidence. Non-green
evidence needs no certification and is returned as-is, so a genuine
failure is still surfaced when the required set cannot be read.

Every failure mode wraps the new scm.ErrChecksUnavailable, and the CI step
now parks with an ask-user outcome after five consecutive evidence-free
polls instead of stalling to its idle timeout. A successful read resets
that budget.

Closes kunchenguid#690

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5UdTu5nTj8kMPC7pidvRG
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

The PR should not merge until required-check app identities are preserved and base branch names are safely encoded in the GitHub API route.

The fallback can incorrectly certify an app-bound required check using a same-named Actions job, while valid base branches containing slashes prevent required-check discovery and force otherwise healthy runs to park.

Files Needing Attention: internal/scm/github/actions_fallback.go

Reviews (1): Last reviewed commit: "fix(scm): derive CI evidence from Action..." | Re-trigger Greptile

Comment on lines +390 to +394
var payload struct {
Contexts []string `json:"contexts"`
Checks []struct {
Context string `json:"context"`
} `json:"checks"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Required app identity is discarded

If branch protection binds a required check to a non-Actions GitHub App and a successful Actions job has the same context name, requiredCheckContexts discards app_id and the name-only mapping treats that job as satisfying the requirement, causing the fallback to certify green without evidence from the required app.

if repo == "" {
return nil, errors.New("no repository to read required checks from")
}
endpoint := fmt.Sprintf("repos/%s/branches/%s/protection/required_status_checks", repo, branch)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Base branch breaks API route

When the PR targets a branch containing a slash or another URL-significant character, interpolating the raw branch into this REST path produces the wrong route, causing required-check discovery to fail and otherwise-green Actions evidence to remain unavailable until the CI step parks.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: inspected the live diff vs main at c312b98c5725. Security-reviewed this fork PR (quinnbot-ai) before approving Actions: SCM/CI evidence fallback only, no credentials, no unexpected network, bound to the configured repo/PR/head.

Classification: corrective for ready-for-pr #690. Slower-correct fallback: rollup stays primary; Actions evidence is used only when the rollup itself is unreadable (capability 403 / insufficient scopes / statusCheckRollup field error). Ambiguous or incomplete Actions listings stay unavailable (ErrActionsEvidenceAmbiguous / missing), never green. Green is certified only when the base branch's required-check set is readable and every required identity maps to exactly one exact-head latest-attempt Actions result. Consecutive evidence-free polls park after 5 instead of stalling to ci_timeout.

VISION: R1 pass as slower-correct fail-closed fallback (does not turn missing/ambiguous listings into a pass). Residual: required-check app_id is not preserved, so a same-named Actions job could theoretically satisfy an app-bound required check — not the listing-ambiguity hole, and slash-encoded base branches fail closed (unavailable) rather than green. R2 n/a; R3 pass (parks for a human when evidence cannot be read); R4 n/a; R5 pass (broad fallback + CI park tests); R6 n/a; R7 pass (fine-grained token stall → regression).

Required CI was action_required (fork). Approved: Require no-mistakes 32542923776, docs 32542923795, CI 32542923812, Guard generated files 32542923813. Greptile FAILURE (not required) for the app_id / slash notes above. mergeable=MERGEABLE / UNSTABLE. Not draft. Not auto-merging until required CI is green on this head. Waiting on CI. It is not waiting on the captain.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: required check PR must be raised via no-mistakes FAILED on c312b98c5725 after fork-CI approval: "This PR was not raised through no-mistakes." The body has no ## Pipeline / Updates from git push no-mistakes attestation.

Not auto-merging. CI red / no-mistakes failing is a nudge, not a captain hold. Waiting on the author to submit this change via git push no-mistakes so the gate writes the pipeline section. Guard generated files is already SUCCESS; remaining CI may still finish for signal. It is not waiting on the captain.

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