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>
39 KiB
Design: Automatic git → Windmill sync (pull-based)
Status: draft for review — exploration on branch explore-git-sync-improvements
1. Problem
Git sync is guided and automatic in one direction only. Windmill → repo is fully
managed: every deploy enqueues a deploymentcallback job that runs the hub sync
script and commits to the repo. The reverse direction (repo → Windmill) requires
each customer to install a GitHub Action that runs wmill sync push with a
long-lived Windmill token stored as a repo secret, against an instance URL that
GitHub-hosted runners must be able to reach.
This breaks down because each customer runs their own instance:
| Customer setup | GH runner → instance | GitHub webhook → instance | Instance → GitHub |
|---|---|---|---|
| Windmill cloud | ✅ | ✅ | ✅ |
| Self-hosted, public URL | ✅ | ✅ | ✅ |
| Self-hosted behind VPN/firewall | ❌ | ❌ | ✅ |
| GHES on the same private network | ❌ (github.com runners) | ✅ | ✅ |
Only instance → GitHub outbound works for everyone. The current GH Action design sits in the worst column; it also requires manual workflow-file installation, token provisioning, and ongoing maintenance per customer.
2. Current state (what already exists)
The building blocks are mostly shipped:
- Instance-side pull already works.
PullWorkspaceModal.svelteruns the hub init script (hubPaths.gitInitRepo) withpull: true,dry_runpreview, anduse_promotion_overridesas a worker job. It clones the repo with the repo credentials and applies the diff to the workspace. It is manual-only today. - Managed GitHub App.
windmill-sync-helper(github.com / *.ghe.com) is installed by the customer; installation tokens are minted by the customer portal (windmill-customer-service, route/github_sync/token) against a JWT the instance stores per installation (workspace_settings.git_app_installations, token logic inbackend/windmill-common/src/git_sync_ee.rs). The portal is stateless: it keeps no installation registry and receives no webhooks. - Self-managed app for GHES. Customers create their own GitHub App; app id +
private key live in instance settings (
GhesAppSettings.svelte,get_self_managed_installation_token). The current setup checklist tells the customer to leave the app webhook inactive. - Webhook machinery. The native GitHub trigger already creates/deletes repo
webhooks via the REST API (
windmill-native-triggers/src/github/external.rs) and GitHub HMAC (X-Hub-Signature-256) verification exists inwindmill-trigger-http/src/http_trigger_auth.rs. - Loop prevention convention. Windmill-authored commits carry a
[WM]prefix so CI can ignore them. - Promotion plumbing. The CLI implements
--promotion <branch>resolvingpromotionOverridesfromwmill.yaml; the pull job already acceptsuse_promotion_overrides. PR creation onwm_deploy/**branches is done by a documented GH Action (gh pr create), not by Windmill.
What is missing is only the trigger (push event → pull job), the routing (event ref → workspace), and PR creation moving instance-side for promotion/fork parity.
3. Goals / non-goals
Goals:
- Zero-CI automatic repo → Windmill deployment: install app, pick repo, done.
- Works for every connectivity profile, degrading gracefully from instant (webhook) to near-real-time (polling).
- Cover all documented setups: basic sync, multi-repo, promotion mode (single- and cross-instance), workspace forks, local-dev git entry.
- No new credentials handed to GitHub (no Windmill tokens as repo secrets).
- Coexist with customer CI: customers who keep the GH Action lose nothing.
Non-goals (this design):
- Replacing customer CI pipelines (tests, lint, custom gates).
- A central event-relay through the portal (delivering the managed app's webhook centrally and forwarding to instances). Considered and rejected for v1 in favor of programmatic repo webhooks; the portal stays a stateless token minter. See §16 for the comparison.
- GitLab/Bitbucket/Azure DevOps parity. The polling tier covers them credential-wise; their webhook tiers are follow-ups.
4. Design overview
flowchart LR
subgraph GitHub
R[Repo] -- "push event" --> W[repo webhook<br/>created via API]
end
subgraph Instance
W -- "HMAC-verified POST" --> E["/api/w/:ws/github_app/webhook"]
P[poller<br/>git ls-remote / schedule] --> REC
E --> REC[reconcile:<br/>ref match? sha moved?<br/>debounce]
REC --> J[pull job<br/>existing hub script, pull:true]
J --> WS[(workspace)]
end
One principle drives the security model: webhooks and polls are hints, the
pull is authoritative. A trigger never carries content; it only causes the
instance to compare the remote HEAD against last_synced_sha using its own
credentials and enqueue the existing pull job if the branch moved. A forged or
replayed trigger can only cause a cheap no-op reconcile.
5. Trigger tier 1 — webhooks
5.1 Managed app (github.com, GHE Cloud orgs)
A GitHub App's own webhook URL is fixed app-wide (it points at the portal), so
per-instance delivery uses repository webhooks created dynamically with the
installation token (POST /repos/{owner}/{repo}/hooks). Requires adding the
Repository webhooks: read & write permission to windmill-sync-helper
(see §9).
Flow when a repo is connected (or auto-pull is enabled on an existing repo):
- Instance generates a per-repo secret, stored in the repo's git-sync settings.
- Instance creates the webhook via API: events
["push"](later"pull_request", §11), URL{base_url}/api/w/{workspace}/github_app/webhook(host-aware, so managed and self-managed/GHES apps use the same per-workspace receiver), secret set. - GitHub immediately delivers a
pingevent. If the ping is not received within ~10 s, the instance deletes the hook and falls back to polling, surfacing "instance not reachable from GitHub — using polling (interval Xm)" in the UI. This doubles as an automatic reachability test; no guessing about firewalls. - Incoming deliveries are verified with
X-Hub-Signature-256against the stored secret (reuse the HMAC code fromhttp_trigger_auth.rs), then handed to the reconciler (§7).
Webhook lifecycle: deleted when the repo is disconnected or auto-pull disabled
(reuse the delete pattern from workspace_integrations.rs); recreated on
settings change; orphan hooks are detectable via GET /repos/.../hooks filtered
by our URL prefix.
5.2 Self-managed app (GHES, *.ghe.com data residency)
The customer owns the app, so the app-level webhook can point directly at the instance — no repo hooks needed, one webhook covers all installed repos:
GhesAppSettingsgains a "Webhook secret" field; the setup checklist changes from "Uncheck Active under Webhook" to "set webhook URL to{base_url}/api/w/{workspace}/github_app/webhook(the per-workspace receiver), subscribe to Push events, paste this generated secret".- That per-workspace endpoint verifies HMAC with the configured secret and
routes by repo full name + installation to matching workspaces (the routing
data is in
workspace_settings). - GHES typically shares a network with the instance, so this works air-gapped — the hardest github.com case is the easiest GHES case.
Polish (optional, recommended): replace the 8-step manual app-creation checklist with the GitHub App Manifest flow (supported on GHES), which pre-configures permissions, events, and webhook URL/secret in one click and eliminates checklist drift.
6. Trigger tier 2 — polling fallback
For private instances, plain token/SSH credentials (no app), and permission-not-yet-approved installs:
- Per auto-pull-enabled repo, on a configurable interval (default 60 s,
surfaced in settings), run
git ls-remote <repo> <tracked refs>and compare againstlast_synced_shaper ref.ls-remoteis a single cheap round-trip; no clone. - Implemented as an internal scheduled task keyed by repo settings id (not a user-visible schedule), running on workers like other background jobs.
- Polling is also the safety net under webhooks (missed deliveries, GitHub outages): when a webhook is active, the poll interval relaxes (e.g. 10 min) rather than turning off. This is the ArgoCD model: poll for correctness, webhook for latency.
7. Reconcile and pull semantics
Routing — an event/poll result is (repo, ref, head_sha, sender):
- Tracked branch (basic sync): ref equals the repo settings branch → pull into the owning workspace.
- Promotion target branch: ref equals a repo's promotion target → pull with
use_promotion_overrides: true(resolvespromotionOverridesfromwmill.yaml, mirroring CLI--promotion). - Fork branches
wm-fork/<parent-branch>/<fork-name>: parse the branch name, route to the fork workspace if it exists; ignore (log) if not. - Anything else: ignore.
- Matching keys on repo +
workspace_id— neverbase_url(avoids the known internal-vs-public URL mismatch that breaks CLI workspace matching). - One repo may match several workspaces (team partitioning with different path
filters): fan out, each workspace pulls with its own filters;
wmill.yamlin the repo stays authoritative for include/exclude.
Loop prevention (pull → deploys → deployment callback → commit → push event):
- Skip events whose sender is the app bot (
windmill-sync-helper[bot]/ the GHES app's bot) or whose head commit message carries the[WM]prefix. - Compare
head_shatolast_synced_shabefore enqueuing; the pull job records the synced sha on success. - Deploys applied by the pull job are tagged so the deployment callback can skip the no-op commit (belt and braces — the diff should be empty anyway).
Concurrency and debounce:
- At most one pull job per (repo, workspace) at a time; rapid pushes coalesce (same pattern as the 5 s deploy-callback batching, keyed on repo).
- Pulls and in-flight Windmill → repo commits on the same branch serialize on the same key to avoid races.
Execution: reuse the existing pull path of the hub init script for v1. Two known fragilities to fix as part of making this an unattended automation:
- The hub script pins a
windmill-cliversion; pin lag has caused 422s against newer backends. The automated path must pin the CLI to the instance's own version (or the pull logic moves into the backend natively as a v2). - Failures must be visible: pull jobs appear in the runs list like deployment
callbacks today (
/runs?job_kinds=deploymentcallbacksequivalent), plus a per-repo "last sync" status chip in git-sync settings and the workspace error handler firing on repeated failures.
8. Settings and schema
Per-repo (GitRepositorySettings, workspace_settings.git_sync):
auto_pull: Option<AutoPullSettings>
enabled: bool
mode: "webhook" | "polling" | "auto" (auto = try webhook, fall back)
poll_interval_s: Option<u32> (default 60; 600 when webhook active)
webhook_id: Option<i64> (GitHub hook id, managed app)
webhook_secret: Option<String> (encrypted; per-repo, managed app)
last_synced_sha: Option<map<ref, sha>> (per tracked ref)
last_pull_status: Option<...> (sha, time, job id, error)
Instance-level (GHES app config): webhook_secret alongside app id/private key.
No new tables; everything extends existing JSONB settings. A migration is only
needed if we decide last_synced_sha/status churn doesn't belong in
workspace_settings (alternative: small git_sync_pull_state table keyed by
workspace + repo path — decide at implementation review).
New instance endpoints (EE):
POST /api/w/{workspace}/github_app/webhook— per-workspace hook receiver for managed and self-managed apps (host-aware), HMAC-verified, returns 202.- Both are unauthenticated-but-verified endpoints; rate-limited; bodies are treated as hints only (§4).
9. GitHub App permission migration
New permissions needed, bundled into one update (each update re-prompts every existing installation's org admin):
| Permission | Used for | Phase |
|---|---|---|
| Repository webhooks: write | dynamic repo hook create/delete | 1 |
| Pull requests: write | instance-side PR creation (promotion/forks) | 2 |
| Checks: write | PR diff preview checks | 3 |
App-only features and their fallbacks: instant webhook sync (falls back to
polling), in-app PR creation for deploy branches (fall back to the
open-pr-on-commit / open-pr-on-fork-commit workflows; the toggles are
hidden for token repos and a set-but-inert toggle logs a warning), and the PR
diff comment/check + deploy status check (no fallback: they need the Checks
API and pull_request webhook deliveries). Token/PAT repositories keep the
full pull direction via polling.
Rollout behavior: until an org approves, webhook creation fails with a distinguishable error → the instance shows "approval pending" and stays on polling. Nothing breaks; latency is the only cost. The self-managed (GHES) checklist/manifest gains the same permissions — no central approval involved.
10. Setup UX
The git-sync wizard (DetectionFlow / repo card) gains a third guided
direction after the existing "test connection" and "initialize repo" steps:
- Toggle: "Automatically deploy changes from Git" (per repo).
- On enable: try webhook (ping self-test) → show resulting mode and latency ("instant via webhook" / "polling every 60s — instance not reachable from GitHub"), with a re-test button.
- The success modal's current "set up GitHub Actions" doc link becomes the advanced/CI path, not the default instruction.
- Promotion-mode repos additionally show "PRs will be opened by Windmill" once §11 lands.
11. Promotion & forks parity: PR creation moves instance-side
Both PR-producing branches are pushed by Windmill's own deploys — wm_deploy/**
(promotion) and wm-fork/** (fork deploys) — so opening the PR moves into the
deploy pipeline itself, per repo toggle: the push job carries a
__git_sync_open_pr marker and its completion hook opens (or reopens) the PR
via the installation token once the push has landed. Outbound-only, so it works
for every connectivity profile — no webhook required.
- Promotion:
promotion_open_prson the promotion repo ("Open a pull request for each deploy branch"). - Forks:
fork_open_prson the parent's sync repo ("Open a pull request when a fork deploys"), read by the fork's deploy callback — parent-owned likesync_forks, zero fork-side setup.
Both default on for newly configured repos and off in storage (upgrades don't
change behavior). ensure_pull_request treats an existing PR as success, so the
documented open-pr-on-commit / open-pr-on-fork-commit Actions can stay
installed for custom titles/CI without duplicate PRs. The merge side is already
covered by §7 routing (push event on the target branch).
12. Later: PR diff preview checks
With pull_request events (webhook tier) and checks: write: on PR
opened/synchronized, run the existing dry_run: true pull and post the diff
summary as a check run. This replicates the CI dry-run preview with zero
customer CI and completes the "Cloudflare Pages" experience: install app →
merges deploy, PRs show a Windmill diff. The commit-level "Deploying… →
Deployed" status (the other half of the Cloudflare feel) is Phase 6.
13. Coverage vs documented setups
| Documented setup | Covered by |
|---|---|
| Basic git sync (workspace ↔ branch) | §5/§6 trigger + existing pull job |
| Multi-repo primary/secondary | per-repo toggle; secondaries stay push-only |
| Promotion mode, single instance | §7 promotion routing + §11 PR creation |
| Promotion mode, cross-instance | each instance triggers independently — strictly better than CI (no cross-instance tokens/URLs) |
Workspace forks (wm-fork/**) |
§7 fork routing + Phase 5 (parent-level sync_forks) |
| PR dry-run preview | §12 (optional follow-up) |
| Local dev, git as entry point | ordinary push events; nothing special |
| Customers with real CI gates | unchanged; pull triggers are idempotent and coexist |
14. Migration of existing users
The defining advantage of the repo-webhook approach: existing git-sync users
already have the managed app installed. Migration is "grant one incremental
permission," not "reconnect." The windmill → repo direction uses the app's
Contents: write grant, which the new Repository webhooks: write permission
does not touch — so nothing existing breaks whether or not a user migrates.
Existing installs are enumerable from workspace_settings.git_app_installations
(each GitInstallation carries installation_id, account_id, and
github_base_url: None = managed github.com app, Some = self-managed/GHES).
That split is the migration cohort boundary.
Cohort A — managed app, already installed (the majority). Only gap is the
Repository webhooks: write grant. GitHub keeps the old grants working while the
new permission sits pending approval, so migration is lazy and never-blocking:
- Ship the app permission update +
auto_pullsettings defaulting off. Zero observable change until a user opts in. - Each managed-app repo gets an "Automatically deploy changes from Git" toggle in the existing settings UI.
- On enable, the instance attempts webhook creation:
- 403 (approval pending) → surface a deep link to the org's app installation page to approve the new permission, and start polling immediately so auto-pull works right now. The user is never blocked on a GitHub org admin.
- Success → ping self-test → webhook mode (or polling if unreachable).
- The instance does not need to hear the approval. The
installation/new_permissions_acceptedevent goes to the app webhook (the portal), not the instance — irrelevant here. The instance just retries webhook creation on its next poll cycle and silently upgrades polling → webhook once the grant lands. No portal state, no callback plumbing.
Cohort B — no app (plain git_repository resource, token/SSH). Nothing to
approve; flipping the toggle goes straight to polling with existing credentials.
Optional upsell: "install the Windmill GitHub App for instant sync."
Cohort C — self-managed / GHES (github_base_url: Some). Customer owns the
app, so there is no central approval. App-level webhooks need no extra
permission — migration is one documented step: paste the instance webhook URL +
generated secret into their app settings (the GhesAppSettings checklist flips
"leave webhook inactive" → "set this URL + secret"). Usually works air-gapped.
Cross-cutting:
- Opt-in, not auto-flipped. Do not silently enable pull on existing repos: some are backup/secondary push-only targets, or hold content the owner does not want deployed back. Surface a prominent "New: deploy automatically from Git" prompt instead.
- Existing CI coexists. A user already running
wmill sync pushvia GH Action keeps it; sha-idempotent triggers make double-firing harmless. Optional cleanup: detect the workflow file via the Contents API and offer one-click removal once webhook pull is confirmed. - The unavoidable cost. The permission bump nags every managed-app installation (even users who never enable auto-pull) with a "requesting updated permissions" prompt until approved or dismissed. No way around it for a single shared app. Mitigation is clear permission-purpose copy; the nag is cosmetic and does not break existing sync.
- Capability gating precedent.
GitRepositorySettings::is_script_meets_min_versionalready gates behavior on the pinned hub-script version — a "this install's app grant supports webhooks" capability flag fits the same pattern.
15. Implementation plan
Staged so each phase is independently shippable and reviewable. Phase 1 alone delivers automatic pull for every customer; webhooks are a latency upgrade.
Phase 1 — polling + reconcile + settings + UX (no app/permission changes)
Backend (EE):
GitRepositorySettings(backend/windmill-common/src/workspaces.rs): addauto_pull: Option<AutoPullSettings>(§8). Non-breaking JSONB addition.- Reconcile + enqueue: new function mirroring
push_git_sync_job(windmill-git-sync/src/git_sync_ee.rs:896) — given(repo, ref, head_sha), resolve matching workspace(s), compare againstlast_synced_sha, and enqueue the pull job with the same 5 s debounce machinery. The pull job for v1 is the existinggitInitRepohub script run withpull: true(mirror the payloadPullWorkspaceModal.sveltealready sends); record the synced sha on success. - Poller: register a periodic task in
backend/src/monitor.rs(alongside the othertokio::time::intervalloops) that, per auto-pull-enabled repo, runsgit ls-remotefor the tracked refs and feeds changes into the reconciler. - Pin the hub-script CLI to the instance version for the unattended path
(UI git-sync runs a version-pinned
windmill-cli; pin lag has caused 422s against newer backends).
Frontend:
GitSyncRepositoryCard.svelte: per-repo "Automatically deploy changes from Git" toggle + last-sync status chip; polling interval input.- Demote the
GitSyncSuccessModal.svelte"set up GitHub Actions" link to an advanced/CI option.
Validation: pull jobs visible in the runs list; error handler fires on repeated
failure. Tests: reconcile routing + sha-compare + loop-prevention (sender/[WM]).
Phase 2 — webhooks (managed app + GHES)
Ops (precedes code): update windmill-sync-helper to request
Repository webhooks: write (bundle Pull requests: write + Checks: write
now too, to avoid a second nag for phases 3–4). Update permission-purpose copy.
Backend (EE):
- Receiver endpoint (§8):
POST /api/w/{workspace}/github_app/webhook(per-workspace, managed and self-managed), HMAC-verified — reuseX-Hub-Signature-256validation fromwindmill-trigger-http/src/http_trigger_auth.rs; routes added togit_sync_ee.rsworkspaced_service/global_service. - Webhook create/delete via installation token — reuse the REST pattern from
windmill-native-triggers/src/github/external.rsand the delete pattern fromworkspace_integrations.rs. Ping self-test with reachability fallback (§5.1). - Lazy approval retry + 403 detection feeding the migration UX (§14 cohort A).
Frontend:
- Toggle now reports resulting mode/latency + re-test button; approval-pending deep link.
GhesAppSettings.svelte: webhook-secret field + updated checklist (and, optional, the App Manifest one-click flow, §5.2).
Phase 3 — PR creation instance-side (promotion + forks, toggled)
- The deploy's push job carries a
__git_sync_open_prmarker when the repo opted in (promotion_open_prson the promotion repo; parent-levelfork_open_prsfor fork deploys); the job-completion hook (maybe_open_git_sync_deploy_prinresult_processor.rs) derives the pushed branch (fork branch wins, else thewm_deploy/**formula) and callsensure_pull_request. Outbound with the installation token, so no webhook reachability is needed; app-backed repos only. - No-op pushes skip PR creation: the push script reports
pushed: falsewhen nothing was committed (e.g. the deploy was itself caused by an auto-pull, so the workspace already matches the repo), and the hook returns early — a PR the user closed isn't recreated by the sync loop. Results without the flag (older script pins) keep ensuring the PR. - Fork-branch routing edge cases (§7) hardened here.
Phase 4 — PR diff preview checks (optional)
- Subscribe
pull_requestevents; on open/synchronize run the existingdry_run: truepull and post the diff as a check run (checks: write). - The same completion hook maintains ONE managed comment on the PR
(Cloudflare deploy-preview style: workspace, status, commit, collapsible
change list), upserted per synchronize via a hidden
<!-- windmill-diff -->marker so reviewers see the current diff without opening the Checks tab.
Phase 5 — fork sync, configured at the parent (replaces the push-on-merge-to-forks GitHub Action) — implemented
Scope rule: Windmill absorbs automation for events it originates or
consumes; Actions remain for custom CI. Of the documented CI/CD workflows:
push-on-merge (repo → prod workspace) is phases 1–2 auto-pull;
push-on-merge-to-forks (fork branch → fork workspace) is this phase; and the
PR-opening workflows (open-pr-on-commit for wm_deploy/**,
open-pr-on-fork-commit for wm-fork/**) react to branches Windmill itself
pushes, so they move into the deploy pipeline as per-repo toggles (§11 / Phase
3) and the Actions become optional alternatives. (A fork_pull_sync "fan the
tracked branch out to every fork" draft was dropped: a fork syncs its own
wm-fork/** branch, never main directly, and blind fan-out would clobber a
dev workspace's local work.)
The premise. create_workspace_fork copies the parent's resources (so the
git_repository resource lands in the fork) and, via clone_workspace_data →
update_workspace_settings, the parent's git_app_installations and git_sync
(keeping the first sync-mode repo — WIN-1559). So a fork already pushes to its
wm-fork/<tracked>/<id> branch on deploy. What it must not inherit is the
auto_pull block: it carries the parent's webhook_id/webhook_secret (the
repo webhook is parent-owned), so update_workspace_settings strips it. The
server also rejects parent-only settings on a fork workspace (enabled
auto_pull, promotion mode, fork_open_prs) — a fork's deploys always target
its wm-fork/** branch, so none of them could take effect there, and fork sync
is exclusively parent-managed.
Design: one parent-level toggle, auto_pull.sync_forks (default on when
auto-pull is enabled). Matches the Action model (configured once at the repo,
fires for every wm-fork/** branch) and needs zero per-fork setup — no fork
webhook, resource, or config; applies to current and future forks.
How a fork branch change reaches the fork workspace (both delivery paths):
- Webhook: the push to a fork branch (
wm-fork/<base>/<suffix>, or a dev workspace's label branch) lands on the parent's webhook.handle_github_git_sync_eventrequires a[WM]-less head commit (a fork's own deploy must not pull itself back) andauto_pull.enabled && sync_forks, then callsreconcile_fork_branch_pull; branches that resolve to no live child no-op. - Polling: the parent's poll tick also lists every
wm-fork/<tracked>/*head plus its dev-workspace children's label branches in one extra call — agit ls-remotepattern (+ explicit refs) for token repos,git/matching-refs(+ per-label head lookups) for app-backed — and reconciles each (poll_git_fork_branchesinmonitor.rs).
Dev workspaces sync with their environment-label branch. A dev workspace's
branch is its label verbatim (dev/staging — the classic env-branch layout,
matching the documented push-on-merge-staging Action), not the namespaced
wm-fork/** form. The label is set at create/attach time and immutable
afterwards (the branch is keyed on it; the old set-label endpoint was removed),
defaulting to dev. The backend passes it with every deploy job
(dev_workspace_label arg → hub script → CLI --dev-workspace-label), the PR
completion hook derives the same branch, and the CLI refuses to deploy when the
label branch equals the checked-out tracked branch (which would otherwise
commit fork content straight to it).
reconcile_fork_branch_pull (windmill-git-sync EE) is the shared routing core:
resolve the branch to a live descendant of this workspace (recursive
parent_workspace_id walk + NOT deleted, so a crafted branch name can't
route a pull into an unrelated workspace) — the wm-fork/<base>/<suffix> form
via parse_fork_branch (windmill-common), or an environment-label branch via
a dev-workspace label lookup — then load the fork's own repo entry and run the
shared reconcile_and_enqueue_pull with the fork's per-ref dedup state and a
clone_ref override so the pull job clones the fork branch instead of the
resource's tracked branch. Descendants (not just direct children) because
forks of a dev workspace also sync through the root's webhook/poller —
only the root can hold auto-pull config. A fork-of-dev roots its wm-fork/**
branch on the dev's label branch and its PR merges back into it (the backend
passes parent_dev_workspace_label with the deploy; fork_open_prs is
resolved at the root ancestor).
State lives with the fork: its repo entry carries a server-written status-only
auto_pull blob (last_synced_sha keyed by the fork branch, last_pull_status;
enabled stays false). persist_auto_pull_state creates that blob when missing.
The fork's repo card shows a read-only "managed in the parent workspace" line
with the fork branch name plus the last pull status; the enable/disable control
exists only on the parent's card.
Perms: the toggle is parent-workspace admin (whoever edits the parent's
git_sync) — the same bar as "who set up the repo secret + workflow." No
per-fork authorization; matches the Action ergonomics.
Non-goals for v1: per-fork opt-out on the parent (default is all forks; add an
exclusion list later if asked); per-fork include/exclude filters (the pull
applies the repo's wmill.yaml like any pull).
Phase 6 — live deploy status check on the commit (Cloudflare-style) — implemented
Replicate the Cloudflare Pages deploy status: a check run that appears in the
commit/PR checks strip, starting in_progress ("Deploying…") and flipping to
completed/success ("Deployed"). This is not a GitHub Action — it's posted
via the Checks API, so it reuses the Phase 4 machinery
(create_check_run/update_check_run) and the checks: write grant already
requested. No new permission, no customer CI. The check lands on the head commit
of the tracked branch — exactly where Cloudflare's "Deployed to production" sits.
Today the deploy path posts nothing back: create_check_run
("status": "in_progress") and update_check_run
("status": "completed" + conclusion + output) already exist and are used for
the PR dry-run diff, but the real deploy pull (tracked-branch push/merge)
doesn't create one. Phase 6 runs that same two-step on the deploy path.
Flow:
- On a tracked-branch push/poll that triggers a deploy pull,
create_check_runon the head sha: name "Windmill" (vs "Windmill diff" for PR checks),in_progress, title "Deploying…", with adetails_urlto the Windmill run / workspace. Keep the returnedcheck_run_id. - Thread
check_run_id+repo_urlinto the pull job — same marker channel as the PR dry-run (add a__git_sync_deploy_checkmarker distinct from the PR-check marker so the completion hook knows which kind). - On completion (generalize
maybe_post_git_sync_pr_checkinresult_processor.rs),update_check_run→completed, conclusionsuccess("Deployed N changes to<workspace>" / "In sync, no changes") orfailurewith the error summary.
Backend (EE) touch points:
create_check_run: add a caller-supplied name + adetails_urlparam (small signature change; PR path keeps "Windmill diff").- Deploy trigger (tracked-branch case in
handle_github_git_sync_event, plus the poller reconcile): best-effort create thein_progresscheck and carry the id into the job payload. App-backed only; never block the deploy on it. - Completion hook: handle the deploy-check marker alongside the PR-check marker.
Implementation note. The
in_progresscheck is created insidereconcile_and_enqueue_pull(one place covers both the webhook and poller paths), best-effort and app-backed-only, then threaded to the pull job as a__git_sync_deploy_checkmarker.create_check_rungainedname+details_url+output_title(the PR path keeps"Windmill diff", no details/output). The completion hookmaybe_post_git_sync_pr_checkwas generalized tomaybe_post_git_sync_check, reading either marker. If the pull can't even be enqueued, the in-progress check is closed as failed so it doesn't hang.
Gating / edges: app-backed only (needs the installation token + checks: write;
PAT/polling repos skip silently); skip self-caused/no-op pulls ([WM]/bot,
sha unchanged) so it doesn't post a check for Windmill's own commits; one check
per (repo, head_sha, workspace) — when several workspaces pull the same commit,
name each with its workspace to disambiguate.
Optional richer variant — GitHub Deployments / Environments. Instead of (or
alongside) the check run, create a Deployment (POST /repos/.../deployments) +
status (POST /repos/.../deployments/{id}/statuses) so the deploy shows in the
repo's Environments timeline ("Production → Deployed"). Needs
deployments: write — a new grant and another approval nag — so keep it
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_idis the fork whoseci_testjobs the check reflects;github_workspace_idis the parent whose GitHub-App installation posts the run (a fork inherits no installations, so it can't mint the token). Plusrepo_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_requesthandler (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_runin_progress onhead_shavia 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_testjob per(trigger, runnable_path)(tested item × test script) in a recent window. The job'striggeris the concrete tested item, so wildcard/multi-target tests are covered without touchingci_test_reference, and no file-path→item reconstruction is needed. Fail-fast on any failed/canceled;successonce 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 idempotentevaluate_and_conclude, which claims the decision with a guardedUPDATE ... WHERE NOT concluded RETURNING(exactly-once) and decouples GitHub delivery viagithub_postedso 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
to push events — delivered centrally to stats.windmill.dev — and forward them
to instances. Its only genuine advantages are (a) zero-friction enablement for
existing installs, since app-level events need no new permission (no per-org
approval, unlike repo-webhook creation), and (b) central delivery observability.
Against that: it does not improve reachability (the portal forwards to the
same instance URL GitHub would hit, so private instances need polling either
way); app-level events fire for every push to every installed repo, org-wide, so
portal cost scales with customers' total push volume rather than synced repos;
the portal becomes stateful (installation→instance registry) and
availability-coupled, losing its current stateless-token-minter property; full
push payloads (commit messages, author emails) transit Windmill infrastructure;
and the instance cannot verify portal signatures, whereas repo webhooks get
per-repo HMAC. The one advantage that stings — the permission-bump nag — is
mitigated contextually (approval shown in settings on enable) and covered by
polling until approved. A narrow portal variant (portal stores events; private
instances poll /github_sync/events with their existing installation JWT) is
the only thing that would lower latency for unreachable instances; parked unless
demanded.
17. Open questions
- Does
last_synced_sha/pull-status churn stay inworkspace_settingsJSONB or move to a dedicated table? (Write frequency vs settings-blob contention.) - Exact debounce window for pull coalescing (reuse 5 s like deploy callbacks, or longer since clones are heavier?).
- Should phase 1 polling default to on for newly connected repos, or strictly opt-in? (Opt-in proposed; revisit after adoption data.)
- GHE Cloud orgs with IP allowlists: confirm hook deliveries to customer instances aren't filtered; the ping self-test catches it operationally either way.
- v2: move pull execution from the hub script into the backend natively (removes CLI pinning and hub round-trip entirely)?