CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
push-workflow-sync.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 61897c4 | 1 | /** |
| 2 | * Sync `.gluecron/workflows/*.yml` from a push into the `workflows` table, | |
| 3 | * then enqueue a run for every non-disabled workflow whose `on:` triggers | |
| 4 | * include `push`. | |
| 5 | * | |
| 6 | * This is the missing half of "push-triggered CI": nothing previously | |
| 7 | * wrote rows into `workflows` on push, so the table only ever got | |
| 8 | * populated by manual/test insertion, and `on: push` triggers never | |
| 9 | * actually fired automatically — only `workflow_dispatch` (manual/MCP), | |
| 10 | * PR slash commands, and `on: schedule` (autopilot tick) worked. Discovered | |
| 11 | * 2026-07-15 while auditing why a sibling platform's Gluecron migration | |
| 12 | * mirror never produced a running CI pipeline. | |
| 13 | * | |
| 14 | * Best-effort per file: a broken workflow YAML is recorded in `errors` | |
| 15 | * and skipped, never thrown — a bug here must not break the push itself. | |
| 16 | */ | |
| 17 | ||
| 18 | import { db } from "../db"; | |
| 19 | import { workflows } from "../db/schema"; | |
| 20 | import { getTree as realGetTree, getBlob as realGetBlob } from "../git/repository"; | |
| 21 | import { parseWorkflow } from "./workflow-parser"; | |
| 22 | import { enqueueRun as realEnqueueRun } from "./workflow-runner"; | |
| 23 | ||
| 24 | const WORKFLOWS_DIR = ".gluecron/workflows"; | |
| 25 | ||
| 26 | export interface PushWorkflowSyncResult { | |
| 27 | synced: number; | |
| 28 | enqueued: number; | |
| 29 | errors: string[]; | |
| 30 | } | |
| 31 | ||
| 32 | export async function syncAndEnqueuePushWorkflows( | |
| 33 | opts: { | |
| 34 | owner: string; | |
| 35 | repo: string; | |
| 36 | repositoryId: string; | |
| 37 | branch: string; | |
| 38 | commitSha: string; | |
| 39 | triggeredBy?: string | null; | |
| 40 | }, | |
| 41 | // Injectable for tests — avoids mock.module() on ../git/repository and | |
| 42 | // ./workflow-runner, both of which are imported by dozens of unrelated | |
| 43 | // test files and would leak a global mock across the whole test run. | |
| 44 | deps: { | |
| 45 | getTree: typeof realGetTree; | |
| 46 | getBlob: typeof realGetBlob; | |
| 47 | enqueueRun: typeof realEnqueueRun; | |
| 48 | } = { getTree: realGetTree, getBlob: realGetBlob, enqueueRun: realEnqueueRun } | |
| 49 | ): Promise<PushWorkflowSyncResult> { | |
| 50 | const result: PushWorkflowSyncResult = { synced: 0, enqueued: 0, errors: [] }; | |
| 51 | ||
| 52 | // getTree never throws — it returns [] for a missing path/exec failure. | |
| 53 | const entries = await deps.getTree(opts.owner, opts.repo, opts.commitSha, WORKFLOWS_DIR); | |
| 54 | const ymlFiles = entries.filter( | |
| 55 | (e) => e.type === "blob" && /\.ya?ml$/i.test(e.name) | |
| 56 | ); | |
| 57 | ||
| 58 | for (const file of ymlFiles) { | |
| 59 | const path = `${WORKFLOWS_DIR}/${file.name}`; | |
| 60 | try { | |
| 61 | const blob = await deps.getBlob(opts.owner, opts.repo, opts.commitSha, path); | |
| 62 | if (!blob || blob.isBinary) continue; | |
| 63 | ||
| 64 | const parseResult = parseWorkflow(blob.content); | |
| 65 | if (!parseResult.ok) { | |
| 66 | result.errors.push(`${file.name}: ${parseResult.error}`); | |
| 67 | continue; | |
| 68 | } | |
| 69 | const { workflow } = parseResult; | |
| 70 | ||
| 71 | const [row] = await db | |
| 72 | .insert(workflows) | |
| 73 | .values({ | |
| 74 | repositoryId: opts.repositoryId, | |
| 75 | name: workflow.name || file.name, | |
| 76 | path, | |
| 77 | yaml: blob.content, | |
| 78 | parsed: JSON.stringify(workflow), | |
| 79 | onEvents: JSON.stringify(workflow.on), | |
| 80 | }) | |
| 81 | .onConflictDoUpdate({ | |
| 82 | target: [workflows.repositoryId, workflows.path], | |
| 83 | set: { | |
| 84 | name: workflow.name || file.name, | |
| 85 | yaml: blob.content, | |
| 86 | parsed: JSON.stringify(workflow), | |
| 87 | onEvents: JSON.stringify(workflow.on), | |
| 88 | updatedAt: new Date(), | |
| 89 | }, | |
| 90 | }) | |
| 91 | .returning({ id: workflows.id, disabled: workflows.disabled }); | |
| 92 | ||
| 93 | if (!row) continue; | |
| 94 | result.synced += 1; | |
| 95 | ||
| 96 | if (!row.disabled && workflow.on.includes("push")) { | |
| 97 | try { | |
| 98 | await deps.enqueueRun({ | |
| 99 | workflowId: row.id, | |
| 100 | repositoryId: opts.repositoryId, | |
| 101 | event: "push", | |
| 102 | ref: opts.branch, | |
| 103 | commitSha: opts.commitSha, | |
| 104 | triggeredBy: opts.triggeredBy ?? null, | |
| 105 | }); | |
| 106 | result.enqueued += 1; | |
| 107 | } catch (err) { | |
| 108 | result.errors.push( | |
| 109 | `enqueue ${file.name}: ${err instanceof Error ? err.message : String(err)}` | |
| 110 | ); | |
| 111 | } | |
| 112 | } | |
| 113 | } catch (err) { | |
| 114 | result.errors.push( | |
| 115 | `${file.name}: ${err instanceof Error ? err.message : String(err)}` | |
| 116 | ); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | return result; | |
| 121 | } |