Skip to content

Commit 35a4570

Browse files
authored
[CRCR] Implement CRCR upstream check run management for L3/L4 jobs (#8119)
# Summary - The current implementation focuses on L3/L4 levels defined in the RFC: pytorch/rfcs#90 - For detailed design for L3/L4, please refer to pytorch/rfcs#93 # Architecture - `webhook` function: - [x] Create PR label handling function for L3 repo (refer to the 3 scenario cases mentioned in pytorch/rfcs#93) - **Scenario 1**: Label arrives before the downstream workflow job is triggered. So we cached this information in Redis using `mark_check_run_wanted` and let the `callback` lambda create this check run. - **Scenario 2**: Label arrives during the downstream workflow job is running. The `callback` lambda will cache workflow information beforehand so it can immediately create the `in_progress` check run. - **Scenario 3**: Label arrives after the downstream workflow job is done. If the cached workflow information is still alive in Redis (3 days by default, could be set by `CRCR_STATUS_TTL`), it will create a `completed` check run immediately. Otherwise, it will not create a check run. - [x] Dispatch function will check for L3 label or L4, and store this information in Redis for the `callback` to check whether a check run is needed. - [x] Create check run and check suite handling functions for the downstream workflow jobs re-run mechanism within the check run. - `callback` function: - [x] Handle upstream check-run creation/update for L3/L4 - **Scenario 1 & L4**: Check in Redis by calling `is_check_run_wanted` to see if this PR needs a check run. If so, immediately create one. - **All Scenarios**: Store workflow information in Redis for check run creation in the `webhook`. - [x] Set "in_progress" zombie check-runs to "time_out" through a sweeper periodically # Changes ```md .github/actions/cross-repo-ci-relay └── action.yml # Add a step to capture job-name for re-run aws/lambda/cross_repo_ci_relay/ ├── tests/ # Add more unit tests ├── allowlist.py # Update utils function for L3 ├── redis_helper.py # Set more keys for L3 ├── gh_helper.py # Update utils function for L3 ├── misc.py # Update utils function for L3 ├── event_handler.py # Create PR label/check run/check suites handling function ├── cleanup_handler.py # Handle zombie check-run └── callback_handler.py # Handle upstream check-run creation ``` # Verification We performed the following scenario verification on our AWS Lambda instance: - L3: - [x] L3 labels named `ciflow/crcr/{device}` are added immediately after the PR is created, and show up in the corresponding check-run on the PR with the name `crcr/{repo}/{workflow_name}/{job_name}`. - [x] After clicking into the check-run, the corresponding information is correct. - [x] L3 labels are added while the workflow job is running, which should show up the `in_progress` check-run. - [x] L3 labels are added after the workflow job is done, which should show up the `completed` check-run. - [x] Check run is updated when the PR with L3 labels is reopened or synchronized. - L4: - [x] Check run should be created after the PR is opened. - [x] Check run is updated when the PR is reopened or synchronized. - Re-run - [x] Clicking the `Re-run` button in each failed check run will trigger the corresponding downstream workflow failed jobs to re-run and update the check run status to `in_progress`. - [x] Clicking the `Re-run all jobs` or `Re-run all failed jobs` button will trigger the corresponding downstream workflow jobs in the check suite and update the corresponding check run status to `in_progress`. # Unit Tests - [x] Unit Tests (Mock) # TODO - Modify PyTorchBot for L3 non-blocking merge: pytorch/pytorch#185612 - Add a new feature in PyTorchBot that automatically mentions related on-call maintainers in L3/L4 repos when check-run fails: #8183 cc @albanD @fffrog @KarhouTam @atalman @huydhn @zxiiro @subinz1 @jewelkm89
1 parent 2c48d59 commit 35a4570

16 files changed

Lines changed: 1613 additions & 48 deletions

File tree

.github/actions/cross-repo-ci-relay-callback/action.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ inputs:
4747
Maximum time in seconds to wait for the callback HTTP request to complete.
4848
required: false
4949
default: 10
50+
job-name:
51+
description: >
52+
Identifier for this job, used to name the upstream check run
53+
(crcr/<repo>/<workflow>/<job-name>). Defaults to github.job, the job's key
54+
in the workflow file, which is fine for a normal single job. For a MATRIX
55+
job every leg shares the same github.job, so you MUST pass a value that
56+
includes the matrix values (for example a job-name of "build-cpu" /
57+
"build-cuda" for a matrix over cpu/cuda) to give each leg its own check
58+
run; otherwise the legs collide on the same name.
59+
required: false
60+
default: ${{ github.job }}
5061

5162
runs:
5263
using: composite
@@ -73,7 +84,7 @@ runs:
7384
OIDC_TOKEN: ${{ steps.oidc.outputs.token }}
7485
CALLBACK_URL: ${{ inputs.callback-url }}
7586
ARTIFACT_URL: ${{ inputs.artifact-url }}
76-
JOB_NAME: ${{ github.job }}
87+
JOB_NAME: ${{ inputs.job-name }}
7788
CHECK_RUN_ID: ${{ job.check_run_id }}
7889
RUN_ID: ${{ github.run_id }}
7990
RUN_ATTEMPT: ${{ github.run_attempt }}

aws/lambda/cross_repo_ci_relay/README.md

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ L1:
2020
L2:
2121
- org3/repo3
2222
L3:
23-
- org4/repo4
23+
device1:
24+
org41/device1-repo: [oncall1, oncall2]
25+
device2:
26+
org42/device2-repo: [oncall3]
2427
L4:
2528
- org5/repo5: oncall1, oncall2
2629
```
@@ -31,6 +34,22 @@ Each entry is either a plain `owner/repo` string or a `owner/repo: oncall1, onca
3134

3235
The allowlist is cached in Redis under the key `crcr:allowlist_yaml` with a TTL controlled by `ALLOWLIST_TTL_SECONDS`. On a Redis error the function falls back to fetching directly from GitHub.
3336

37+
### Repository levels
38+
39+
Every level is dispatched to. Higher levels add capabilities on top, and each level includes everything the lower ones grant:
40+
41+
| Level | Dispatch | Report results to HUD | Upstream check run on the PR |
42+
|---|---|---|---|
43+
| **L1** | ✅ | — | — |
44+
| **L2** | ✅ | ✅ | — |
45+
| **L3** | ✅ | ✅ | ✅ — only when the PR carries the matching `ciflow/crcr/<device>` label |
46+
| **L4** | ✅ | ✅ | ✅ — always |
47+
48+
- **L3** entries are grouped by *device*. A repo registered under `device1` gets an upstream check run only when the PR has the `ciflow/crcr/device1` label, letting maintainers opt specific PRs into a backend's CI. The `[oncall, ...]` list is the oncalls associated with that repo.
49+
- **L4** repos always get an upstream check run, with no label required.
50+
51+
The dispatch/HUD-reporting path (L1/L2) is covered below; the upstream check run path (L3/L4) is covered in [Upstream Check Runs](#upstream-check-runs-l3-and-l4).
52+
3453
## Reporting Results from Downstream CI
3554

3655
L2+ downstream repositories can report the status of their CI workflows back to the relay server using the [`cross-repo-ci-relay-callback`](../../../.github/actions/cross-repo-ci-relay-callback/action.yml) composite action.
@@ -44,12 +63,12 @@ The callback endpoint validates incoming callbacks and forwards them to HUD for
4463
- **Identity**: the `Authorization: Bearer <oidc-token>` header is verified against GitHub's JWKS. The OIDC `repository` claim is a trusted identity for the caller and is used for the L2+ allowlist check. Relay forwards this trusted value to HUD as a top-level `verified_repo` field; HUD should prefer it over anything self-reported in `callback_payload`.
4564
- **Repo level**: Relay determines the downstream repository's allowlist level (L1–L4) and forwards it to HUD as `downstream_repo_level`. This authoritative level information is determined once by the relay, ensuring HUD doesn't need to recompute it and avoiding synchronization/timing issues if tiering information becomes dynamic.
4665
- **Schema validation**: Relay validates that required fields (`delivery_id` and `workflow.status`) are present in the callback body. Missing fields result in a `400` error to signal contract violations to the caller. HUD receives validated data and does not need to perform schema checks.
47-
- **State machine**: Relay maintains a **unified state machine** in Redis to validate callback lifecycles, compute timing metrics, and support per-workflow tracking:
48-
- **Unified structure**: Single enum `CallbackState` with states `DISPATCHED` (webhook side, keyed by sentinel `run_id=0, run_attempt=0`), `IN_PROGRESS`, and `COMPLETED` (callback side, per-workflow). State records stored as JSON: `{"state": "...", "timestamp": 1234.56}`.
66+
- **State machine**: Relay maintains a **unified state machine** in Redis to validate callback lifecycles, compute timing metrics, and support per-job tracking:
67+
- **Unified structure**: Single enum `CallbackState` with states `DISPATCHED` (webhook side, keyed by sentinel `run_id=0, run_attempt=0`), `IN_PROGRESS`, and `COMPLETED` (callback side, per-job). State records stored as JSON: `{"state": "...", "timestamp": 1234.56}`.
4968
- **Dispatch validation**: `DISPATCHED` state proves valid webhook origin. Callbacks without this state are rejected (no prior dispatch).
50-
- **Workflow-level tracking**: Each workflow has independent state and timestamps keyed by `{run_id}:{run_attempt}` (`crcr:state:{delivery_id}:{repo}:{run_id}:{run_attempt}`). Supports multiple workflows per webhook.
69+
- **Job-level tracking**: Each job has independent state and timestamps keyed by `{run_id}:{run_attempt}:{job_name}` (`crcr:state:{delivery_id}:{repo}:{run_id}:{run_attempt}:{job_name}`). Supports multiple jobs (and workflows) per webhook; the `job_name` suffix keeps concurrent jobs of one run from colliding.
5170
- **Timing metrics**: `queue_time = dispatch_timestamp → in_progress_timestamp`, `execution_time = in_progress_timestamp → completed_timestamp`. Timestamps extracted from state records.
52-
- **State transitions**: Rejects invalid flows (`COMPLETED` without prior `IN_PROGRESS`, duplicate `IN_PROGRESS` for the same `{run_id}:{run_attempt}`, duplicate `COMPLETED`, callbacks without a prior `DISPATCHED` record).
71+
- **State transitions**: Rejects invalid flows (`COMPLETED` without prior `IN_PROGRESS`, duplicate `IN_PROGRESS` for the same `{run_id}:{run_attempt}:{job_name}`, duplicate `COMPLETED`, callbacks without a prior `DISPATCHED` record).
5372
Note that the direction graph below is for a single check run, reruns have different `run_attempt` and are treated as separate workflows, so they won't violate the state machine since they won't have a prior `IN_PROGRESS` or `COMPLETED` record.
5473
```mermaid
5574
stateDiagram-v2
@@ -87,7 +106,10 @@ The HUD request looks like (two top-level namespaces: `trusted` and `untrusted`)
87106
"conclusion": "success",
88107
"name": "CI",
89108
"url": "https://github.com/org/repo/actions/runs/123",
90-
"job_name": "my-ci-job",
109+
"run_id": "123", // stable across re-runs of the same run
110+
"run_attempt": "2", // increments each re-run; (run_id, run_attempt) distinguishes attempts
111+
"job_name": "build-cuda", // from the action's job-name input (defaults to github.job)
112+
"check_run_id": "456", // unique per attempt
91113
"started_at": "2026-05-04T20:48:28Z", // when status == in_progress, else None
92114
"completed_at": "2026-05-04T21:23:45Z", // when status == completed, else None
93115
"test_results": { "passed": 42, "failed": 3, "skipped": 5 },
@@ -133,6 +155,8 @@ All three attacks are **scoped to the attacker's own OIDC-authenticated repo ide
133155

134156
- The downstream repository must be listed at level **L2 or higher** in the allowlist.
135157
- The **calling job** must declare `permissions: id-token: write` so that the action can mint a GitHub OIDC token for authentication.
158+
- The upstream check run is named after the calling job. By default it uses `github.job` (the job's workflow-file key), which is fine for a normal single job. A **matrix** job shares one `github.job` across all legs, so pass the `job-name` input with the matrix values (e.g. `job-name: build-${{ matrix.config }}`) to give each leg its own check run; otherwise the legs collide on the same name.
159+
- Each downstream **job** that should surface as its own upstream check run must invoke the action itself (one `in_progress`, one `completed`), so multi-job / matrix workflows report per job with no extra configuration.
136160

137161
### Usage
138162

@@ -175,6 +199,41 @@ jobs:
175199
| `callback-url` | **yes** | — | Callback endpoint URL (production Lambda URL; set once at the workflow level) |
176200
| `artifact-url` | no | `''` | URL to downstream-hosted artifacts (logs, reports, results) |
177201

202+
## Upstream Check Runs (L3 and L4)
203+
204+
For L3 and L4 repositories the relay creates a **check run on the upstream PR** that mirrors the downstream workflow's status, so the upstream sees downstream CI as a normal PR check. This is built on top of the same callback used for HUD reporting (L2+), so an L3/L4 repo still reports results to HUD exactly as described above.
205+
206+
### How it works
207+
208+
Check runs are **per job**, named `crcr/<downstream_repo>/<workflow_name>/<job_name>`, and created from the **callback**, not at dispatch time. `job_name` comes from the action's `job-name` input, which defaults to `github.job`. Because every leg of a matrix shares one `github.job`, a matrix workflow must pass a distinct `job-name` per leg (e.g. `build-${{ matrix.config }}`) so the legs get separate check runs instead of colliding. Each downstream job reports its own callbacks (one `in_progress`, one `completed`), so a multi-job or matrix workflow surfaces one check run per job on the upstream PR instead of a single collapsed one:
209+
210+
- On an `in_progress` callback the relay creates an in-progress check run linking to the downstream run.
211+
- On a `completed` callback it creates a completed check run carrying the downstream `conclusion`.
212+
213+
The relay always **creates** a new check run rather than editing an existing one. GitHub only surfaces the latest check run of a given name on a commit, so each new one naturally supersedes the previous. Scoping the name by `job_name` keeps distinct jobs from overwriting each other while an in_progress → completed pair for the *same* job still self-supersedes. This keeps the logic stateless and makes reruns and reopens self-correcting.
214+
215+
### L3 label timing
216+
217+
For L3 the upstream check run is gated on the `ciflow/crcr/<device>` label, which a maintainer can add at any point relative to the workflow. The relay covers all three orderings:
218+
219+
1. **Label present before dispatch** — the callback sees the label and creates the check run directly.
220+
2. **Label added while the workflow is running** — the `pull_request.labeled` handler reads the cached downstream job state (`crcr:dispatch_job:<head_sha>:<repo>`, a Redis hash whose fields are `<workflow_name>:<job_name>`) and backfills an in-progress check run for **every** job that has already reported.
221+
3. **Label added after the workflow finished** — the `labeled` handler creates a completed check run for each cached job directly from that state.
222+
223+
Because the downstream echoes back the *dispatch-time* payload — whose labels can be stale, e.g. on **reopen** where no fresh `labeled` event fires — the relay records a per-commit "check run wanted" flag (`crcr:check_run_wanted:<head_sha>:<repo>`) at dispatch time (when the label is already present) and in the `labeled` handler. The callback consults this flag so it still creates the check run when the echoed labels don't reflect the PR's current state.
224+
225+
### Re-running checks
226+
227+
A developer can re-run downstream CI directly from the upstream PR's checks UI. Re-runs happen at the **workflow-run** level via `rerun-failed-jobs`: the downstream `run_id` is stored as each check run's `external_id`, and GitHub rejects re-running individual jobs of a run that is already running, so one run-level call re-runs all failed jobs together without conflict. The GitHub App subscribes to the `check_run` and `check_suite` events, and the relay handles their `rerequested` action:
228+
229+
- **Re-run a single check** (`check_run` `rerequested`) — the downstream repo is parsed from the check run name (`crcr/<owner>/<repo>/<workflow_name>/<job_name>`) and the `run_id` from its `external_id`. The relay verifies the repo is L3+ and re-runs the failed jobs of that run (`POST /repos/<repo>/actions/runs/<run_id>/rerun-failed-jobs`). A 403 "run already running" is treated as a benign no-op.
230+
- **Re-run all checks** (`check_suite` `rerequested`) — the CRCR app owns a single check suite per commit, so the relay lists every check run in that suite, dedupes them by `(repo, run_id)`, and re-runs the failed jobs of each distinct L3+ run. Check runs with no `external_id` are skipped.
231+
232+
Because the re-run carries the **original `delivery_id`** but a **new `check_run_id`** (GitHub mints fresh check runs per attempt), the two CI timing metrics behave differently:
233+
234+
- **`execution_time`** (in_progress → completed) is correct as-is — the new `check_run_id` gets its own fresh state records.
235+
- **`queue_time`** (dispatch → in_progress) would otherwise be measured against the *original* dispatch timestamp (possibly days old). Thus, using `run_attempt` keyword in payload to identify whether it is a trustworthy value.
236+
178237
## Build, Deploy, and Test
179238

180239
### Deployment layout

aws/lambda/cross_repo_ci_relay/callback/callback_handler.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import utils.redis_helper as redis_helper
77
from redis.exceptions import RedisError
8+
from utils import gh_helper
89
from utils.allowlist import AllowlistLevel, AllowlistMap, load_allowlist
910
from utils.config import RelayConfig
1011
from utils.hud import forward_to_hud
@@ -13,6 +14,7 @@
1314
CallbackStateRecord,
1415
DISPATCH_RUN_ATTEMPT,
1516
DISPATCH_RUN_ID,
17+
extract_pr_labels,
1618
HTTPException,
1719
)
1820
from utils.redis_helper import check_rate_limit
@@ -107,6 +109,10 @@ def _update_state_and_compute_metrics(
107109
108110
Both metrics default to None when the required prior state is unavailable
109111
(e.g. Redis cache miss or rerun without matching prior record).
112+
113+
For a re-run, ``queue_time`` is measured against the original dispatch (the
114+
re-run reuses its delivery_id), so it is not a meaningful queue interval —
115+
HUD distinguishes re-runs via ``workflow.run_attempt`` in the forwarded body.
110116
"""
111117
if status not in ("in_progress", "completed"):
112118
raise HTTPException(400, f"unknown callback status: {status!r}")
@@ -168,6 +174,66 @@ def _update_state_and_compute_metrics(
168174
return ci_metrics
169175

170176

177+
def _create_upstream_check_run(
178+
*,
179+
config: RelayConfig,
180+
verified_repo: str,
181+
delivery_id: str,
182+
status: str,
183+
conclusion: str | None,
184+
run_id: int,
185+
head_sha: str,
186+
workflow_name: str,
187+
job_name: str | None,
188+
details_url: str,
189+
) -> None:
190+
"""Create a new upstream check run mirroring the downstream job's status.
191+
192+
Called for L3+ repos (only when a check run is wanted and head_sha is known)
193+
before HUD forwarding, so a HUD error cannot block the upstream PR check.
194+
195+
Every callback always *creates* a new check run (never edits an existing
196+
one): GitHub only surfaces the latest check run of a given name on a commit,
197+
so each new one supersedes the previous, keeping the logic stateless.
198+
Best-effort: a GitHub failure must not fail the callback.
199+
"""
200+
output = gh_helper.build_check_run_output(
201+
status, conclusion, details_url, verified_repo
202+
)
203+
try:
204+
upstream_token = gh_helper.get_repo_access_token(
205+
config.github_app_id,
206+
config.github_app_private_key,
207+
config.upstream_repo,
208+
)
209+
cr_id = gh_helper.create_check_run(
210+
token=upstream_token,
211+
repo_full_name=config.upstream_repo,
212+
name=gh_helper.check_run_name(verified_repo, workflow_name, job_name),
213+
head_sha=head_sha,
214+
status=status,
215+
conclusion=conclusion,
216+
details_url=details_url,
217+
# Store the downstream run_id so a check-run rerequest can re-run
218+
# the failed jobs of that workflow run.
219+
external_id=str(run_id),
220+
output=output,
221+
)
222+
logger.info(
223+
"upstream check run created delivery_id=%s repo=%s status=%s cr_id=%s",
224+
delivery_id,
225+
verified_repo,
226+
status,
227+
cr_id,
228+
)
229+
except Exception:
230+
logger.exception(
231+
"failed to create upstream check run delivery_id=%s repo=%s",
232+
delivery_id,
233+
verified_repo,
234+
)
235+
236+
171237
def handle(config: RelayConfig, body: dict, verified_repo: str) -> dict:
172238
"""Forward a downstream callback to HUD.
173239
@@ -188,7 +254,7 @@ def handle(config: RelayConfig, body: dict, verified_repo: str) -> dict:
188254
result = _verify_access(config, verified_repo)
189255
if result is None:
190256
return {"ok": True, "status": "ignored"}
191-
_, repo_level = result
257+
allowlist, repo_level = result
192258

193259
delivery_id, status, run_id, run_attempt, workflow_name, job_name = (
194260
_parse_callback_body(body)
@@ -237,6 +303,54 @@ def handle(config: RelayConfig, body: dict, verified_repo: str) -> dict:
237303
job_name=job_name,
238304
)
239305

306+
# L3+: cache the job and create its upstream check run. Runs after state
307+
# validation (above) but before HUD forwarding, so a HUD error cannot block
308+
# the PR check. Without a head_sha there is neither a check run to create nor
309+
# a cache entry the label handler could ever look up.
310+
if repo_level.value >= AllowlistLevel.L3.value:
311+
pr_field = (body.get("payload") or {}).get("pull_request") or {}
312+
head_sha = (pr_field.get("head") or {}).get("sha", "")
313+
if head_sha:
314+
conclusion = (body.get("workflow") or {}).get("conclusion")
315+
details_url = f"https://github.com/{verified_repo}/actions/runs/{run_id}"
316+
317+
# Always cache the job so a later label event can backfill its check
318+
# run, even when we are not creating one now.
319+
redis_helper.set_dispatch_job(
320+
config,
321+
head_sha,
322+
verified_repo,
323+
status,
324+
conclusion,
325+
details_url,
326+
run_id=str(run_id),
327+
workflow_name=workflow_name,
328+
job_name=job_name,
329+
)
330+
331+
needs_cr = allowlist.needs_check_run(verified_repo, extract_pr_labels(body))
332+
if not needs_cr and repo_level == AllowlistLevel.L3:
333+
# The downstream's echoed payload labels may not reflect the PR's
334+
# current state (e.g. on reopen). Fall back to the per-commit flag
335+
# recorded at dispatch / label time for this (head_sha, repo).
336+
needs_cr = redis_helper.is_check_run_wanted(
337+
config, head_sha, verified_repo
338+
)
339+
340+
if needs_cr:
341+
_create_upstream_check_run(
342+
config=config,
343+
verified_repo=verified_repo,
344+
delivery_id=delivery_id,
345+
status=status,
346+
conclusion=conclusion,
347+
run_id=run_id,
348+
head_sha=head_sha,
349+
workflow_name=workflow_name,
350+
job_name=job_name,
351+
details_url=details_url,
352+
)
353+
240354
if status == "in_progress":
241355
redis_helper.add_in_progress_tracker(
242356
config, delivery_id, verified_repo, run_id, run_attempt, job_name=job_name

0 commit comments

Comments
 (0)