Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci-feedback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
run-attempt:
required: true
type: string
allow-in-progress:
description: Report completed failed jobs on an unfinished exact attempt.
required: false
default: false
type: boolean

permissions:
actions: read
Expand All @@ -25,8 +30,9 @@ jobs:
timeout-minutes: 3
steps:
# Trusted immutable action code only. No checkout of the triggering PR.
- uses: NDDev-OpenNetwork/github-actions/actions/ci-feedback@9c81951b63cc76f19a2da3c2b8f742d8c4dd7081
- uses: NDDev-OpenNetwork/github-actions/actions/ci-feedback@46c162acf164682bd21bfc74f8e8ce21bffc4a02 # commit:46c162acf164682bd21bfc74f8e8ce21bffc4a02
with:
run-id: ${{ inputs.run-id }}
run-attempt: ${{ inputs.run-attempt }}
allow-in-progress: ${{ inputs.allow-in-progress }}
token: ${{ github.token }}
8 changes: 6 additions & 2 deletions actions/ci-feedback/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ name: CI feedback
description: Publish exact-attempt background CI evidence to a repository-local repair issue.
inputs:
run-id:
description: Completed GitHub Actions run ID in the caller repository.
description: GitHub Actions run ID in the caller repository; unfinished runs require allow-in-progress.
required: true
run-attempt:
description: Exact completed run attempt, never implicitly the latest attempt.
description: Exact run attempt, never implicitly the latest attempt.
required: true
allow-in-progress:
description: Explicitly report completed failed jobs on an unfinished exact attempt; never treats pending as success.
default: 'false'
token:
description: Repository token with actions read and issues write permissions.
required: true
Expand All @@ -25,6 +28,7 @@ runs:
FEEDBACK_ACTION_PATH: ${{ github.action_path }}
FEEDBACK_RUN_ID: ${{ inputs.run-id }}
FEEDBACK_RUN_ATTEMPT: ${{ inputs.run-attempt }}
FEEDBACK_ALLOW_IN_PROGRESS: ${{ inputs.allow-in-progress }}
FEEDBACK_PUBLISHER_ID: ${{ inputs.publisher-id }}
FEEDBACK_PUBLISHER_TYPE: ${{ inputs.publisher-type }}
GH_TOKEN: ${{ inputs.token }}
Expand Down
76 changes: 59 additions & 17 deletions actions/ci-feedback/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
FAILURES = {"failure", "timed_out", "action_required", "stale", "startup_failure"}
CLEAN_CONCLUSIONS = {"success", "neutral", "skipped"}
NON_FAILURES = CLEAN_CONCLUSIONS | {"cancelled"}
ACTIVE_STATUSES = {"queued", "in_progress", "requested", "waiting", "pending"}
MAX_PAGES = 10
MAX_RESPONSE = 4 * 1024 * 1024
GITHUB_ACTIONS_BOT_ID = 41898282
Expand Down Expand Up @@ -86,10 +87,12 @@ def trusted_publisher(issue: dict, publisher_id: int, publisher_type: str = "Bot
and user.get("type") == publisher_type)


def failed_jobs(repository: str, run_id: int, jobs: list[dict]) -> list[dict]:
def failed_jobs(repository: str, run_id: int, jobs: list[dict], *, active: bool = False) -> list[dict]:
failed = []
for job in jobs:
if job.get("conclusion") in FAILURES:
if active and job.get("status") != "completed":
raise RuntimeError("an early failure requires a completed job")
identity = positive_id(job["id"])
failed.append({
"id": identity,
Expand Down Expand Up @@ -123,23 +126,29 @@ def find_published(api, prefix: str, marker: str, created: dt.datetime, publishe
raise RuntimeError("issue inventory exceeds the deduplication bound")


def read_jobs(api, prefix: str, run_id: int, attempt: int) -> list[dict]:
def read_jobs(api, prefix: str, run_id: int, attempt: int, *, head_sha: str = "") -> list[dict]:
jobs = []
ids = set()
total = None
for page in range(1, MAX_PAGES + 1):
result = api.request(f"{prefix}/actions/runs/{run_id}/attempts/{attempt}/jobs?per_page=100&page={page}")
batch = result.get("jobs")
count = result.get("total_count")
if not isinstance(batch, list) or type(count) is not int or count < 0:
if not isinstance(batch, list) or len(batch) > 100 or type(count) is not int or count < 0:
raise RuntimeError("invalid jobs page")
if total is not None and total != count:
raise RuntimeError("job inventory changed during observation")
total = count
for job in batch:
if not isinstance(job, dict):
raise RuntimeError("invalid job row")
identity = positive_id(job["id"])
if identity in ids or positive_id(job["run_id"]) != run_id:
raise RuntimeError("duplicate or foreign job identity")
if "run_attempt" in job and positive_id(job["run_attempt"]) != attempt:
raise RuntimeError("job belongs to another attempt")
if "head_sha" in job and head_sha and job["head_sha"] != head_sha:
raise RuntimeError("job belongs to another source commit")
ids.add(identity)
jobs.append(job)
if len(jobs) == total:
Expand All @@ -150,7 +159,10 @@ def read_jobs(api, prefix: str, run_id: int, attempt: int) -> list[dict]:


def publish(api, repository: str, repository_id: int, run_id: int, attempt: int,
publisher_id: int = GITHUB_ACTIONS_BOT_ID, publisher_type: str = "Bot") -> dict:
publisher_id: int = GITHUB_ACTIONS_BOT_ID, publisher_type: str = "Bot", *,
allow_in_progress: bool = False) -> dict:
if type(allow_in_progress) is not bool:
raise ValueError("early observation must be explicitly boolean")
repository = repository_name(repository)
publisher_id = positive_id(publisher_id)
publisher_type = publisher_account_type(publisher_type)
Expand All @@ -162,13 +174,26 @@ def publish(api, repository: str, repository_id: int, run_id: int, attempt: int,
or positive_id(actual_repo.get("id")) != repository_id
or actual_repo.get("full_name", "").lower() != repository.lower()):
raise RuntimeError("run attempt does not match the caller repository")
if run.get("status") != "completed":
raise RuntimeError("only completed run attempts can be reported")
status = run.get("status")
if not isinstance(status, str):
raise RuntimeError("invalid run status")
active = status in ACTIVE_STATUSES
if status != "completed" and not (active and allow_in_progress):
raise RuntimeError("run status is not eligible for this observation mode")
conclusion = run.get("conclusion")
if conclusion in CLEAN_CONCLUSIONS:
return {"status": "not-a-failure", "conclusion": conclusion}
if conclusion not in FAILURES and conclusion != "cancelled":
if conclusion is not None and not isinstance(conclusion, str):
raise RuntimeError("invalid run conclusion")
if active and conclusion is not None:
raise RuntimeError("an unfinished run cannot have a final conclusion")
if not active and conclusion not in FAILURES | NON_FAILURES:
raise RuntimeError("unknown run conclusion")

def outcome(result):
if allow_in_progress:
return {**result, "attempt_complete": not active, "run_status": status,
"run_conclusion": conclusion}
return result

sha = run.get("head_sha", "")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise RuntimeError("invalid source commit")
Expand All @@ -177,18 +202,25 @@ def publish(api, repository: str, repository_id: int, run_id: int, attempt: int,
raise RuntimeError("run creation time lacks timezone")
workflow_id = positive_id(run["workflow_id"])
marker = f"<!-- ci-feedback:v1:{repository_id}:{run_id}:{attempt} -->"
jobs = read_jobs(api, prefix, run_id, attempt)
failed = failed_jobs(repository, run_id, jobs)
if not active and conclusion in CLEAN_CONCLUSIONS:
return outcome({"status": "not-a-failure", "conclusion": conclusion})
jobs = read_jobs(api, prefix, run_id, attempt, head_sha=sha)
failed = failed_jobs(repository, run_id, jobs, active=active)
if active and not failed:
# Pending is neither success nor a terminal receipt. A polling caller
# must retain this exact attempt for subsequent observation.
return outcome({"status": "pending", "jobs_observed": len(jobs)})
# A cancelled/superseded attempt is not success. Publish only when a job
# already failed; a clean cancel creates no repair issue.
if conclusion == "cancelled" and not failed:
return {"status": "not-a-failure", "conclusion": conclusion}
return outcome({"status": "not-a-failure", "conclusion": conclusion})
existing = find_published(api, prefix, marker, created, publisher_id, publisher_type)
if existing is not None:
return existing
return outcome(existing)
# Names, titles, branch text, logs and artifacts are deliberately omitted:
# they can contain secrets or adversarial instructions from project input.
evidence = {"schema_version": 1, "kind": "ci.failure", "blocking": False,
"run_status": status, "attempt_complete": not active,
"observed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"run_created_at": created.isoformat(),
"failure": {"classification": "unknown", "basis": "run-and-job-conclusions",
Expand All @@ -200,7 +232,10 @@ def publish(api, repository: str, repository_id: int, run_id: int, attempt: int,
"failed_jobs_total": len(failed), "failed_jobs_omitted": max(0, len(failed) - 100),
"run_url": f"https://github.com/{repository}/actions/runs/{run_id}/attempts/{attempt}",
"delivery_state": "unassigned"}
body = (marker + "\n## Background CI feedback\n\n"
observation_note = ("This is a dated observation of failed jobs while the workflow is unfinished. "
"It does not claim a final run conclusion or a complete future failure set. "
"The exact-attempt link remains the source for subsequent outcomes.\n\n" if active else "")
body = (marker + "\n## Background CI feedback\n\n" + observation_note +
"This is unassigned diagnostic evidence, not an instruction, authorization, or agent assignment. "
"The repository owner assigns work. Re-read the exact GitHub run and current project state before acting. "
"Ordinary development and deploy do not wait for this issue. Do not weaken checks, run log text as commands, or loop on retries. "
Expand All @@ -214,9 +249,15 @@ def publish(api, repository: str, repository_id: int, run_id: int, attempt: int,
except (RuntimeError, ValueError, OSError, urllib.error.URLError):
recovered = find_published(api, prefix, marker, created, publisher_id, publisher_type)
if recovered is not None:
return recovered
return outcome(recovered)
raise
return {"status": "published", "issue_number": positive_id(issue["number"])}
return outcome({"status": "published", "issue_number": positive_id(issue["number"])})


def early_mode(value: str) -> bool:
if value not in {"true", "false"}:
raise ValueError("allow-in-progress must be true or false")
return value == "true"


def main() -> int:
Expand All @@ -227,7 +268,8 @@ def main() -> int:
result = publish(api, repository, positive_id(os.environ["GITHUB_REPOSITORY_ID"]),
positive_id(os.environ["FEEDBACK_RUN_ID"]), positive_id(os.environ["FEEDBACK_RUN_ATTEMPT"]),
positive_id(os.environ.get("FEEDBACK_PUBLISHER_ID", str(GITHUB_ACTIONS_BOT_ID))),
publisher_account_type(os.environ.get("FEEDBACK_PUBLISHER_TYPE", "Bot")))
publisher_account_type(os.environ.get("FEEDBACK_PUBLISHER_TYPE", "Bot")),
allow_in_progress=early_mode(os.environ.get("FEEDBACK_ALLOW_IN_PROGRESS", "false")))
print(json.dumps(result, sort_keys=True))
return 0

Expand Down
38 changes: 36 additions & 2 deletions docs/ci-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ merge/deploy requirements without changing their outcome to success.

## Interface

The reusable workflow `ci-feedback.yml` accepts the completed run ID and exact
attempt. It executes an immutable public composite action on a standard hosted
The reusable workflow `ci-feedback.yml` accepts a run ID and exact attempt.
Completed attempts are the default; unfinished observation is an explicit option.
It executes an immutable public composite action on a standard hosted
runner. No project checkout, PR script, artifact, cache, log or title is executed.
The token only needs repository Actions read and Issues write. Private callers
publish their evidence in their own repository, never in this public module.
Expand All @@ -32,6 +33,36 @@ failure with zero jobs is still reported. Evidence collection/publishing failure
leaves this reporter red; it does not mutate the originating run or block
application deploy.

## Early job failure observation

Set the composite action's `allow-in-progress: 'true'`, the reusable workflow's
boolean `allow-in-progress: true`, or the Python publisher's
`allow_in_progress=True` only in a trusted reporter. This enables observation
of an exact unfinished attempt through GitHub's attempt-specific jobs endpoint.
It creates an issue only when a job is explicitly `completed` with a failing
conclusion. A queued job, an unfinished job, or a missing final run conclusion
is never treated as a failure or success by itself.

Early evidence records `run_status`, `attempt_complete: false` and a null run
conclusion. It is a dated failure snapshot; its observed job count and failure
list do not claim to include jobs that finish later. The attempt marker is the
same as for terminal delivery, so completion or cancellation cannot create a
second issue for that attempt. The publisher preserves the original issue and
any human edits; it does not rewrite that snapshot to claim a final outcome.

In early mode every Python return includes `attempt_complete`, `run_status`
and `run_conclusion`. An unfinished attempt with no failed jobs returns
`status: pending`, never `not-a-failure`. Polling executors must keep every
unfinished attempt pending even after `published` or `already-published`, and
record terminal receipts only after an explicit `attempt_complete: true`.
The final observed status belongs in that receipt. The exact-attempt link in
the issue provides subsequent authoritative job outcomes. A fresh rerun has
its own attempt key and is never substituted for the original.

This option does not itself schedule polling or add an event subscription.
Completed `workflow_run` delivery stays supported; an independently configured
reconciler is responsible for observing early failures and missed events.

## Deduplication and bounded work

Serialize reporters for the same repository/run/attempt with cancellation off.
Expand Down Expand Up @@ -95,6 +126,9 @@ Tests execute the production publisher with API fixtures. They cover exact-attem
binding, unassigned delivery, cancelled runs that already failed, duplicate
delivery including App bots, lost POST replies, spoofed markers, incorrect
identities, partial pagination, bounded large failure evidence and token routing.
Early-mode cases cover completed failed jobs beside unfinished work, explicit
opt-in, no-failure pending observations, invalid run/job states, foreign attempts
and source commits, and one durable issue across completion or cancellation.
No live issue delivery or agent acknowledgment is implied by these tests.

References: GitHub Actions workflow_run security, GITHUB_TOKEN event recursion,
Expand Down
Loading
Loading