feat(git-sync): gate GitHub PRs on Windmill CI test results (WIN-2051)

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) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-14 18:12:29 +02:00
parent e57973cbd9
commit e33bc30ff3
16 changed files with 379 additions and 1 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
99eef24e2f0402b9a997cde5f67be52ee5d54b0e
f1f2a46349cf6ce07517b51d3f6eb5a57954fc0d
@@ -0,0 +1 @@
DROP TABLE IF EXISTS git_sync_ci_test_check;
@@ -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;
+5
View File
@@ -3113,6 +3113,11 @@ pub async fn poll_git_auto_pull(db: &Pool<Postgres>) {
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)
@@ -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");
+47
View File
@@ -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