@@ -329,6 +329,114 @@ Other admin surfaces are scoped to later sub-sprints: |
| 329 | 329 | - S41g: `shithubd admin actions cancel <run-id>` flips |
| 330 | 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 | 440 | ## What S41a deliberately doesn't do |
| 333 | 441 | |
| 334 | 442 | - No trigger pipeline. `domain_events` aren't matched against `on:` |