mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+44
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+59
@@ -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 @@
|
||||
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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user