From e33bc30ff350ead15315d05e2d2ca22eaded764a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 18:12:29 +0200 Subject: [PATCH] feat(git-sync): gate GitHub PRs on Windmill CI test results (WIN-2051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Windmill CI tests" GitHub check run on any PR against the tracked branch, so a workspace-fork customer can mark it a required status check and have Windmill's own CI tests (the `// test:` annotation) block the PR — replacing the GitHub Action that polls the CI-test results API. Driven by the pull_request webhook (same event as the Phase 4 diff check): a new git_sync_ci_test_check table tracks one check per (fork workspace, PR head commit); a per-ci_test-job completion hook (result_processor) and the git-sync poller sweep (monitor) conclude it. Reuses the Checks: write grant from #9552; token repos keep the documented Action. See docs Phase 7. EE logic lives in windmill-ee-private (git_sync_ee.rs); see ee-repo-ref.txt. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6030cd3f3a50ff715cd5e400084f43f8a75ed.json | 44 ++++++++++++++ ...607358fca2ffefbc52ef9675d1d7295431954.json | 15 +++++ ...f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4.json | 36 +++++++++++ ...0d5d77ad34ef7a10fb80daae4066c6a73fdf8.json | 22 +++++++ ...1f19ffb6f1ab81649944809bafe3f89acd9e8.json | 14 +++++ ...31d7274f5c6b55f299b96d621d28cc35fe71a.json | 18 ++++++ ...0972375357d2bf0f7c336c8a01e3216f6d041.json | 23 ++++++++ ...4abcae4a86be358eee76b62d27f19637a4db8.json | 16 +++++ ...08684cd5c245fa22f2761bb92484bf6b0b0b3.json | 23 ++++++++ ...7024285eaa4c9bb5b959c6054ab617eed74bb.json | 59 +++++++++++++++++++ backend/ee-repo-ref.txt | 2 +- ...142042_add_git_sync_ci_test_check.down.sql | 1 + ...14142042_add_git_sync_ci_test_check.up.sql | 33 +++++++++++ backend/src/monitor.rs | 5 ++ .../windmill-worker/src/result_processor.rs | 22 +++++++ docs/git-sync-pull-design.md | 47 +++++++++++++++ 16 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-12a974d89a4401f7e574ce6f0ce6030cd3f3a50ff715cd5e400084f43f8a75ed.json create mode 100644 backend/.sqlx/query-1404f8052df47b05ea47787269a607358fca2ffefbc52ef9675d1d7295431954.json create mode 100644 backend/.sqlx/query-48cab7f9b303c906ad8c0819816f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4.json create mode 100644 backend/.sqlx/query-5dabbd2c6d767cbf24d8fcd387e0d5d77ad34ef7a10fb80daae4066c6a73fdf8.json create mode 100644 backend/.sqlx/query-8d5a245c5f6674d72fe0d4bc1ff1f19ffb6f1ab81649944809bafe3f89acd9e8.json create mode 100644 backend/.sqlx/query-94f5f569a98bb7749301721ec1231d7274f5c6b55f299b96d621d28cc35fe71a.json create mode 100644 backend/.sqlx/query-bd7d60d79131ca135a9cc5bcb640972375357d2bf0f7c336c8a01e3216f6d041.json create mode 100644 backend/.sqlx/query-d15152c3a0f4509ffe7a439059f4abcae4a86be358eee76b62d27f19637a4db8.json create mode 100644 backend/.sqlx/query-f7f0bd318ea709c39ca5f03433b08684cd5c245fa22f2761bb92484bf6b0b0b3.json create mode 100644 backend/.sqlx/query-ff294ecae7d308fe50e93e144137024285eaa4c9bb5b959c6054ab617eed74bb.json create mode 100644 backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql create mode 100644 backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql diff --git a/backend/.sqlx/query-12a974d89a4401f7e574ce6f0ce6030cd3f3a50ff715cd5e400084f43f8a75ed.json b/backend/.sqlx/query-12a974d89a4401f7e574ce6f0ce6030cd3f3a50ff715cd5e400084f43f8a75ed.json new file mode 100644 index 0000000000..f0c408ada1 --- /dev/null +++ b/backend/.sqlx/query-12a974d89a4401f7e574ce6f0ce6030cd3f3a50ff715cd5e400084f43f8a75ed.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, github_workspace_id, head_sha, repo_url, check_run_id\n FROM git_sync_ci_test_check\n WHERE NOT concluded OR NOT github_posted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "github_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "head_sha", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "repo_url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "check_run_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "12a974d89a4401f7e574ce6f0ce6030cd3f3a50ff715cd5e400084f43f8a75ed" +} diff --git a/backend/.sqlx/query-1404f8052df47b05ea47787269a607358fca2ffefbc52ef9675d1d7295431954.json b/backend/.sqlx/query-1404f8052df47b05ea47787269a607358fca2ffefbc52ef9675d1d7295431954.json new file mode 100644 index 0000000000..b3459f03ff --- /dev/null +++ b/backend/.sqlx/query-1404f8052df47b05ea47787269a607358fca2ffefbc52ef9675d1d7295431954.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check SET github_posted = true\n WHERE workspace_id = $1 AND head_sha = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1404f8052df47b05ea47787269a607358fca2ffefbc52ef9675d1d7295431954" +} diff --git a/backend/.sqlx/query-48cab7f9b303c906ad8c0819816f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4.json b/backend/.sqlx/query-48cab7f9b303c906ad8c0819816f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4.json new file mode 100644 index 0000000000..f370a7ecdc --- /dev/null +++ b/backend/.sqlx/query-48cab7f9b303c906ad8c0819816f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check\n SET concluded = true, conclusion = $3, concluded_at = now()\n WHERE workspace_id = $1 AND head_sha = $2 AND NOT concluded\n RETURNING check_run_id, github_workspace_id, repo_url", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "check_run_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "github_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "repo_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "48cab7f9b303c906ad8c0819816f38e0dc11be5e1b50ac72b00bf7e6e0dfcfa4" +} diff --git a/backend/.sqlx/query-5dabbd2c6d767cbf24d8fcd387e0d5d77ad34ef7a10fb80daae4066c6a73fdf8.json b/backend/.sqlx/query-5dabbd2c6d767cbf24d8fcd387e0d5d77ad34ef7a10fb80daae4066c6a73fdf8.json new file mode 100644 index 0000000000..51af96e172 --- /dev/null +++ b/backend/.sqlx/query-5dabbd2c6d767cbf24d8fcd387e0d5d77ad34ef7a10fb80daae4066c6a73fdf8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT head_sha FROM git_sync_ci_test_check WHERE workspace_id = $1 AND NOT concluded", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "head_sha", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5dabbd2c6d767cbf24d8fcd387e0d5d77ad34ef7a10fb80daae4066c6a73fdf8" +} diff --git a/backend/.sqlx/query-8d5a245c5f6674d72fe0d4bc1ff1f19ffb6f1ab81649944809bafe3f89acd9e8.json b/backend/.sqlx/query-8d5a245c5f6674d72fe0d4bc1ff1f19ffb6f1ab81649944809bafe3f89acd9e8.json new file mode 100644 index 0000000000..0d1e6ca879 --- /dev/null +++ b/backend/.sqlx/query-8d5a245c5f6674d72fe0d4bc1ff1f19ffb6f1ab81649944809bafe3f89acd9e8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM git_sync_ci_test_check\n WHERE concluded AND github_posted\n AND concluded_at < now() - make_interval(secs => $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Float8" + ] + }, + "nullable": [] + }, + "hash": "8d5a245c5f6674d72fe0d4bc1ff1f19ffb6f1ab81649944809bafe3f89acd9e8" +} diff --git a/backend/.sqlx/query-94f5f569a98bb7749301721ec1231d7274f5c6b55f299b96d621d28cc35fe71a.json b/backend/.sqlx/query-94f5f569a98bb7749301721ec1231d7274f5c6b55f299b96d621d28cc35fe71a.json new file mode 100644 index 0000000000..afb938de62 --- /dev/null +++ b/backend/.sqlx/query-94f5f569a98bb7749301721ec1231d7274f5c6b55f299b96d621d28cc35fe71a.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO git_sync_ci_test_check\n (workspace_id, github_workspace_id, head_sha, repo_url, check_run_id,\n created_at, concluded, conclusion, concluded_at, github_posted)\n VALUES ($1, $2, $3, $4, $5, now(), false, NULL, NULL, false)\n ON CONFLICT (workspace_id, head_sha) DO UPDATE SET\n github_workspace_id = EXCLUDED.github_workspace_id,\n repo_url = EXCLUDED.repo_url,\n check_run_id = EXCLUDED.check_run_id,\n created_at = now(),\n concluded = false,\n conclusion = NULL,\n concluded_at = NULL,\n github_posted = false", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "94f5f569a98bb7749301721ec1231d7274f5c6b55f299b96d621d28cc35fe71a" +} diff --git a/backend/.sqlx/query-bd7d60d79131ca135a9cc5bcb640972375357d2bf0f7c336c8a01e3216f6d041.json b/backend/.sqlx/query-bd7d60d79131ca135a9cc5bcb640972375357d2bf0f7c336c8a01e3216f6d041.json new file mode 100644 index 0000000000..cba6a1a3af --- /dev/null +++ b/backend/.sqlx/query-bd7d60d79131ca135a9cc5bcb640972375357d2bf0f7c336c8a01e3216f6d041.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(jc.status::text, 'running') as \"status!\"\n FROM (\n SELECT DISTINCT ON (trigger, runnable_path) id\n FROM v2_job\n WHERE workspace_id = $1\n AND trigger_kind = 'ci_test'\n AND created_at > now() - make_interval(secs => $2)\n ORDER BY trigger, runnable_path, created_at DESC\n ) latest\n LEFT JOIN v2_job_completed jc ON jc.id = latest.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Float8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bd7d60d79131ca135a9cc5bcb640972375357d2bf0f7c336c8a01e3216f6d041" +} diff --git a/backend/.sqlx/query-d15152c3a0f4509ffe7a439059f4abcae4a86be358eee76b62d27f19637a4db8.json b/backend/.sqlx/query-d15152c3a0f4509ffe7a439059f4abcae4a86be358eee76b62d27f19637a4db8.json new file mode 100644 index 0000000000..fc59ff164d --- /dev/null +++ b/backend/.sqlx/query-d15152c3a0f4509ffe7a439059f4abcae4a86be358eee76b62d27f19637a4db8.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check SET check_run_id = $3\n WHERE workspace_id = $1 AND head_sha = $2 AND check_run_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d15152c3a0f4509ffe7a439059f4abcae4a86be358eee76b62d27f19637a4db8" +} diff --git a/backend/.sqlx/query-f7f0bd318ea709c39ca5f03433b08684cd5c245fa22f2761bb92484bf6b0b0b3.json b/backend/.sqlx/query-f7f0bd318ea709c39ca5f03433b08684cd5c245fa22f2761bb92484bf6b0b0b3.json new file mode 100644 index 0000000000..9dd36fdff5 --- /dev/null +++ b/backend/.sqlx/query-f7f0bd318ea709c39ca5f03433b08684cd5c245fa22f2761bb92484bf6b0b0b3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT head_sha FROM git_sync_ci_test_check\n WHERE workspace_id = $1 AND head_sha != $2 AND NOT concluded", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "head_sha", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f7f0bd318ea709c39ca5f03433b08684cd5c245fa22f2761bb92484bf6b0b0b3" +} diff --git a/backend/.sqlx/query-ff294ecae7d308fe50e93e144137024285eaa4c9bb5b959c6054ab617eed74bb.json b/backend/.sqlx/query-ff294ecae7d308fe50e93e144137024285eaa4c9bb5b959c6054ab617eed74bb.json new file mode 100644 index 0000000000..1b0687476f --- /dev/null +++ b/backend/.sqlx/query-ff294ecae7d308fe50e93e144137024285eaa4c9bb5b959c6054ab617eed74bb.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT repo_url, check_run_id, github_workspace_id, conclusion,\n created_at, concluded, github_posted\n FROM git_sync_ci_test_check\n WHERE workspace_id = $1 AND head_sha = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "repo_url", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "check_run_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "github_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "conclusion", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "concluded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "github_posted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "ff294ecae7d308fe50e93e144137024285eaa4c9bb5b959c6054ab617eed74bb" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index eba565f93f..d9281df950 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -99eef24e2f0402b9a997cde5f67be52ee5d54b0e +f1f2a46349cf6ce07517b51d3f6eb5a57954fc0d diff --git a/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql b/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql new file mode 100644 index 0000000000..b21e2bb474 --- /dev/null +++ b/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS git_sync_ci_test_check; diff --git a/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql b/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql new file mode 100644 index 0000000000..3bae198302 --- /dev/null +++ b/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql @@ -0,0 +1,33 @@ +-- One "Windmill CI tests" GitHub check run per (workspace, deployed commit): a +-- git-sync deploy opens the check in_progress and it is concluded once the CI +-- tests triggered by that deploy settle, so the results can gate a GitHub PR. +CREATE TABLE git_sync_ci_test_check ( + -- The fork workspace whose CI tests gate the PR: keys the row, and its `ci_test` + -- jobs are what the check reflects (a fork inherits no git_app_installations). + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id), + head_sha VARCHAR(64) NOT NULL, + -- The workspace whose GitHub App installation posts the check run (the parent that + -- owns the repo webhook); forks can't mint the token themselves. + github_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id), + repo_url TEXT NOT NULL, + -- NULL when the GitHub check-run creation failed; the poller retries the create. + check_run_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + concluded BOOLEAN NOT NULL DEFAULT false, + conclusion TEXT, + concluded_at TIMESTAMPTZ, + -- Decoupled from `concluded` so a failed check-run PATCH is retried by the + -- poller instead of hanging a required check on GitHub. + github_posted BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (workspace_id, head_sha) +); + +-- Rows still needing action (create retry, conclusion, timeout, delivery retry). +-- A row drops out only once it is both concluded and delivered to GitHub, so the +-- per-job conclusion hook and the poller sweeper both scan a small live set. +CREATE INDEX idx_git_sync_ci_test_check_pending + ON git_sync_ci_test_check (workspace_id) + WHERE NOT concluded OR NOT github_posted; + +GRANT ALL ON git_sync_ci_test_check TO windmill_user; +GRANT ALL ON git_sync_ci_test_check TO windmill_admin; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 381d65ace2..0cf2e8ab40 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -3113,6 +3113,11 @@ pub async fn poll_git_auto_pull(db: &Pool) { tracing::error!("git auto-pull: poll error: {e:#}"); } + // WIN-2051: backstop for the "Windmill CI tests" checks — retry a failed GitHub + // create/deliver, conclude checks whose tests settled, time out stuck ones, and + // prune old rows. Shares this tick's advisory lock so only one replica sweeps. + windmill_common::git_sync_ee::sweep_ci_test_checks(db).await; + if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)") .bind(GIT_AUTO_PULL_LOCK_ID) .execute(&mut *lock_conn) diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 552d802f9e..9d127410af 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -1461,6 +1461,16 @@ pub async fn process_completed_job( maybe_post_git_sync_check(db, &job_id, &workspace_id, true, result.get()).await; maybe_open_git_sync_deploy_pr(db, &job_id, &workspace_id, result.get()).await; } + // WIN-2051: a CI test job just finished → advance any open "Windmill CI tests" + // PR check for its workspace (idempotent; races safely with the poller). + #[cfg(all(feature = "enterprise", feature = "private"))] + if matches!( + job.trigger_kind, + Some(windmill_common::jobs::JobTriggerKind::CiTest) + ) { + windmill_common::git_sync_ee::evaluate_and_conclude_ci_test_checks(db, &workspace_id) + .await; + } // Asset-trigger fan-out: best-effort, never propagates errors. // Internal eligibility checks gate to top-level Script/Preview runs; @@ -1573,6 +1583,18 @@ pub async fn process_completed_job( maybe_post_git_sync_check(db, &job.id, &job.workspace_id, false, result.get()).await; maybe_reconcile_git_sync_auto_pull(db, &job.id, &job.workspace_id, false).await; } + // WIN-2051: a failed CI test job also settles its check — advance it now. + #[cfg(all(feature = "enterprise", feature = "private"))] + if matches!( + job.trigger_kind, + Some(windmill_common::jobs::JobTriggerKind::CiTest) + ) { + windmill_common::git_sync_ee::evaluate_and_conclude_ci_test_checks( + db, + &job.workspace_id, + ) + .await; + } if job.is_flow_step() { if let Some(parent_job) = job.parent_job { tracing::error!(parent_flow = %parent_job, subflow = %job.id, "process completed job error, updating flow status"); diff --git a/docs/git-sync-pull-design.md b/docs/git-sync-pull-design.md index 8961b0f047..a1067da4d2 100644 --- a/docs/git-sync-pull-design.md +++ b/docs/git-sync-pull-design.md @@ -618,6 +618,53 @@ repo's **Environments** timeline ("Production → Deployed"). Needs opt-in / later. The check-run version is the cheap default and matches the visual Cloudflare parity without a new permission. +### Phase 7 — CI test results check (WIN-2051) — implemented + +Surfaces Windmill's own CI tests (the `// test: script/...` annotation) as a +**"Windmill CI tests"** check run on **any PR** against the tracked branch, so a customer +can mark it a **required status check** and have Windmill CI results gate the PR — +replacing the documented GitHub Action that polls `ci_test_results_batch`. App-backed +only; reuses the Phase 4 `Checks: write` grant, so no new permission. Token repos keep the +Action. + +Driven by the **`pull_request` webhook** — the same event Phase 4 already reacts to — +rather than the deploy push/pull, so it's uniform across how the PR's commit came to exist +(a fork deploy that pushes `wm-fork/**` and opens the PR, or an external push that gets +pulled in). CI tests run as separate async `ci_test` jobs in the **fork workspace** the PR +corresponds to; the check reflects that fork's current results on the PR head. + +- **State** — `git_sync_ci_test_check(workspace_id, head_sha)` (new table). `workspace_id` + is the **fork** whose `ci_test` jobs the check reflects; `github_workspace_id` is the + **parent** whose GitHub-App installation posts the run (a fork inherits no + installations, so it can't mint the token). Plus `repo_url`, `check_run_id` (NULL if the + create failed), `created_at`, `concluded`, `conclusion`, `concluded_at`, `github_posted`. + Partial index `(workspace_id) WHERE NOT concluded OR NOT github_posted` (the live set the + hook + poller scan). +- **Open** — in the `pull_request` handler (opened/synchronize/reopened, base = tracked): + resolve the fork workspace from the head ref (reusing the fork-branch routing; + `resolve_pr_head_workspace`), supersede the fork's previous open check (`neutral` — a + synchronize advanced the head), `create_check_run` in_progress on `head_sha` via the + parent's installation, persist the intent row (even on create failure), then evaluate. +- **Conclude** — verdict from the fork's **current** CI test status: the newest `ci_test` + job per `(trigger, runnable_path)` (tested item × test script) in a recent window. The + job's `trigger` is the concrete tested item, so wildcard/multi-target tests are covered + without touching `ci_test_reference`, and no file-path→item reconstruction is needed. + Fail-fast on any failed/canceled; `success` once all settle (or "No CI tests" when none + ran); `skipped` (debounce-superseded) ignored. No time-scope is needed — the tests ran + before the PR event, so the fork's live status is authoritative. +- **Drivers** — a per-`ci_test`-job completion hook (low latency) and the git-sync poller + (the backstop: retries the GitHub create/deliver, times stuck checks out after 30 min, + prunes old rows). Both call one idempotent `evaluate_and_conclude`, which claims the + decision with a guarded `UPDATE ... WHERE NOT concluded RETURNING` (exactly-once) and + decouples GitHub delivery via `github_posted` so a failed PATCH is retried, not hung. + +Invariants: supersession concludes a stale head's check on synchronize so one PR shows one +live check; the parent posts because forks can't mint the token; the timeout stops a hung +test job from blocking a required check forever. Known limits (accepted for v1): a plain +feature-branch or contributor-fork PR resolves to no fork workspace and gets no check; the +fork's status is workspace-wide (all its tested items), which for the one-fork-per-PR model +equals the PR's scope. + ## 16. Alternatives considered **Portal as webhook proxy (the rejected "option 2").** Subscribe the managed app