@@ -329,6 +329,114 @@ Other admin surfaces are scoped to later sub-sprints: |
| 329 | - S41g: `shithubd admin actions cancel <run-id>` flips | 329 | - S41g: `shithubd admin actions cancel <run-id>` flips |
| 330 | `cancel_requested`. | 330 | `cancel_requested`. |
| 331 | | 331 | |
| | 332 | +## Trigger pipeline (S41b) |
| | 333 | + |
| | 334 | +Three layers between a triggering event and a queued `workflow_run`: |
| | 335 | + |
| | 336 | +``` |
| | 337 | +caller (push_process / pulls.Create / pr_jobs.PRSynchronize / dispatch HTTP) |
| | 338 | + │ |
| | 339 | + └─► worker.Enqueue(KindWorkflowTrigger, JobPayload) |
| | 340 | + │ |
| | 341 | + └─► trigger.Handler picks up: |
| | 342 | + Discover .shithub/workflows/*.yml at HEAD SHA |
| | 343 | + Parse each (skip + log on Error diagnostics) |
| | 344 | + Match each against trigger.Event |
| | 345 | + Enqueue each match |
| | 346 | + │ |
| | 347 | + └─► trigger.Enqueue (one tx): |
| | 348 | + INSERT workflow_runs (ON CONFLICT DO NOTHING) |
| | 349 | + INSERT workflow_jobs per parsed job |
| | 350 | + INSERT workflow_steps per parsed step |
| | 351 | + (commit) |
| | 352 | + checks.Create per job (post-tx, idempotent |
| | 353 | + via ExternalID 'workflow_run:<id>:job:<key>') |
| | 354 | +``` |
| | 355 | + |
| | 356 | +### Idempotency on the triggering event |
| | 357 | + |
| | 358 | +The robust pattern, not a UNIQUE on `(repo_id, head_sha)`. Each |
| | 359 | +caller constructs a stable `trigger_event_id` from its triggering |
| | 360 | +event's identity: |
| | 361 | + |
| | 362 | +| Caller | trigger_event_id format | |
| | 363 | +| ------------------- | ------------------------------------------------ | |
| | 364 | +| push_process | `push:<push_event_id>` | |
| | 365 | +| pulls.Create | `pr_opened:<pr_id>:<head_sha>` | |
| | 366 | +| pr_jobs.PRSynchronize | `pr_synchronize:<pr_id>:<head_sha>` | |
| | 367 | +| dispatch HTTP | `dispatch:<file>:<sha>:<8-byte-random-hex>` | |
| | 368 | +| schedule sweep (S41b-2) | `schedule:<workflow_id>:<window_start_unix>` | |
| | 369 | + |
| | 370 | +Migration 0051 adds `workflow_runs.trigger_event_id` (text NOT NULL |
| | 371 | +DEFAULT '') with a partial UNIQUE on |
| | 372 | +`(repo_id, workflow_file, trigger_event_id) WHERE trigger_event_id <> ''`. |
| | 373 | +The trigger handler does `INSERT … ON CONFLICT DO NOTHING` so: |
| | 374 | + |
| | 375 | +- Worker retries (the same push_process replay) → no duplicate runs. |
| | 376 | +- Admin replays via `shithubd admin run-job workflow:trigger ...` |
| | 377 | + → no duplicate runs. |
| | 378 | +- Re-runs (the future "Re-run" button) explicitly construct a NEW |
| | 379 | + trigger_event_id (`rerun:<original_run_id>:<request_uuid>`) and |
| | 380 | + chain back via `parent_run_id`. History is preserved, no |
| | 381 | + collision. |
| | 382 | + |
| | 383 | +Each caller's collision-free namespace is short-lived and |
| | 384 | +human-debuggable: a Postgres operator can grep |
| | 385 | +`workflow_runs.trigger_event_id` to see exactly which triggering |
| | 386 | +event produced a given run. |
| | 387 | + |
| | 388 | +### Filter evaluation |
| | 389 | + |
| | 390 | +`trigger.Match(workflow, event)` is a pure function (no I/O, no DB). |
| | 391 | +For each event kind: |
| | 392 | + |
| | 393 | +- **push**: branch vs tag classified from the ref; only the matching |
| | 394 | + filter list applies (a `branches:` filter rejects tag pushes and |
| | 395 | + vice versa). `paths:` (when set) requires at least one changed |
| | 396 | + path to match. Empty filter = match-all. |
| | 397 | +- **pull_request**: `types:` defaults to |
| | 398 | + `[opened, synchronize, reopened]` when omitted (GHA parity). |
| | 399 | + `branches:` applies to the **base** ref. `paths:` as for push. |
| | 400 | +- **schedule**: requires the workflow to declare the cron expression |
| | 401 | + that fired. The sweep is the source of truth for which cron |
| | 402 | + fires; we just gate on declaration. Avoids interpreting cron |
| | 403 | + semantics in two places. |
| | 404 | +- **workflow_dispatch**: matches whenever the workflow declares |
| | 405 | + `on.workflow_dispatch`. |
| | 406 | + |
| | 407 | +Glob semantics in `branches:`/`tags:`/`paths:`: minimatch subset |
| | 408 | +with `*` (single segment), `**` (any), `/**` end-anchor (optional |
| | 409 | +trailing path), `**/` start-anchor, and `!exclude` (last-match-wins, |
| | 410 | +exclusion-only list implies include-all). |
| | 411 | + |
| | 412 | +### Collaborator gate |
| | 413 | + |
| | 414 | +Per the S41b spec's "external-PR support is parked" decision: PR |
| | 415 | +triggers (both `opened` and `synchronize`) only fire when the PR's |
| | 416 | +author is the repo's owning user. Conservative — drops legitimate |
| | 417 | +non-owner collaborators in the org-repo case. Expanding the gate |
| | 418 | +requires plumbing `policy.Can` into the worker context, which we |
| | 419 | +defer to S41g where the lifecycle work touches that surface anyway. |
| | 420 | + |
| | 421 | +### Operator surface |
| | 422 | + |
| | 423 | +- `POST /{owner}/{repo}/actions/workflows/{file}/dispatches` |
| | 424 | + Body: `{"ref": "...", "inputs": {"key": "value"}}` (both optional; |
| | 425 | + ref defaults to the repo's default branch). Returns 204 No Content |
| | 426 | + on success. Synchronous trigger.Enqueue (no discovery — file is |
| | 427 | + named in the URL). Auth: requires repo write. |
| | 428 | + |
| | 429 | +### What S41b deliberately doesn't do |
| | 430 | + |
| | 431 | +- Run jobs. Runs sit in `queued` forever — S41c+ runner work. |
| | 432 | +- Schedule sweep. Cron-driven triggers split into S41b-2 to keep |
| | 433 | + this PR reviewable; the trigger pipeline accepts schedule events, |
| | 434 | + but no caller produces them yet. S41b-2 adds the sweep + the |
| | 435 | + `robfig/cron/v3` dep + `shithubd-cron.service` wiring. |
| | 436 | +- External-PR triggers. Conservative collaborator gate above. |
| | 437 | +- `workflow_run` webhook events. S41h adds the webhook event family |
| | 438 | + + atom feed. |
| | 439 | + |
| 332 | ## What S41a deliberately doesn't do | 440 | ## What S41a deliberately doesn't do |
| 333 | | 441 | |
| 334 | - No trigger pipeline. `domain_events` aren't matched against `on:` | 442 | - No trigger pipeline. `domain_events` aren't matched against `on:` |