Add independent workflow sources to automations

This commit is contained in:
Scott Werner 2026-08-29 09:10:44 -04:00
parent dbe2e7564e
commit 03f81d1f25
42 changed files with 2520 additions and 299 deletions

2
Cargo.lock generated
View file

@ -2380,8 +2380,10 @@ dependencies = [
"fabro-types",
"hex",
"serde",
"serde_json",
"sha2 0.10.9",
"sqlx",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"tokio",

View file

@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import {
EMPTY_AUTOMATION_FORM,
automationFormValuesFromRun,
automationToFormValues,
isFormValid,
workflowSourceFromFormValues,
} from "./automation-form";
describe("automation workflow source form values", () => {
test("the default and create-from-run forms inherit the target checkout", () => {
expect(workflowSourceFromFormValues(EMPTY_AUTOMATION_FORM)).toBeUndefined();
const values = automationFormValuesFromRun({
title: "Release",
workflow: { name: "Release", graph_name: "release", slug: "release" },
repository: {
name: "fabro-sh/fabro",
origin_url: "https://github.com/fabro-sh/fabro.git",
},
sandbox: null,
} as any);
expect(values.usesSeparateWorkflowSource).toBe(false);
expect(workflowSourceFromFormValues(values)).toBeUndefined();
});
test("branch, tag, and commit sources serialize unambiguously", () => {
const base = {
...EMPTY_AUTOMATION_FORM,
usesSeparateWorkflowSource: true,
workflowSourceRepository: " fabro-sh/workflows ",
};
expect(workflowSourceFromFormValues({
...base,
workflowSourceKind: "branch",
workflowSourceRef: " main ",
})).toEqual({ repo: "fabro-sh/workflows", kind: "branch", ref: "main" });
expect(workflowSourceFromFormValues({
...base,
workflowSourceKind: "tag",
workflowSourceRef: " v1.2.3 ",
})).toEqual({ repo: "fabro-sh/workflows", kind: "tag", ref: "v1.2.3" });
expect(workflowSourceFromFormValues({
...base,
workflowSourceKind: "commit",
workflowSourceRef: "ABCDEF0123456789ABCDEF0123456789ABCDEF01",
})).toEqual({
repo: "fabro-sh/workflows",
kind: "commit",
ref: "abcdef0123456789abcdef0123456789abcdef01",
});
});
test("separate source fields are required and commits need 40 hex characters", () => {
const validBase = {
...EMPTY_AUTOMATION_FORM,
id: "nightly",
name: "Nightly",
environmentId: "daytona-smoke",
targetRepository: "fabro-sh/app",
targetBranch: "main",
workflow: "release",
usesSeparateWorkflowSource: true,
workflowSourceRepository: "fabro-sh/workflows",
workflowSourceKind: "commit" as const,
workflowSourceRef: "0123456789abcdef0123456789abcdef01234567",
};
expect(isFormValid(validBase)).toBe(true);
expect(isFormValid({ ...validBase, workflowSourceRepository: "" })).toBe(false);
expect(isFormValid({ ...validBase, workflowSourceRef: "main" })).toBe(false);
});
test("editing preserves an explicit source even when it equals the target", () => {
const values = automationToFormValues({
id: "nightly",
revision: "revision",
name: "Nightly",
description: null,
target: { kind: "git", repo: "fabro-sh/fabro", branch: "main" },
workflow: "release",
workflow_source: { repo: "fabro-sh/fabro", kind: "branch", ref: "main" },
triggers: [],
});
expect(values.usesSeparateWorkflowSource).toBe(true);
expect(values.workflowSourceRepository).toBe("fabro-sh/fabro");
expect(values.workflowSourceKind).toBe("branch");
expect(values.workflowSourceRef).toBe("main");
expect(workflowSourceFromFormValues({
...values,
usesSeparateWorkflowSource: false,
})).toBeUndefined();
});
});

View file

@ -3,6 +3,8 @@ import { Link } from "react-router";
import { Switch } from "@headlessui/react";
import type {
Automation,
AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind,
AutomationTrigger,
Environment,
Run,
@ -26,29 +28,37 @@ export interface AutomationFormValues {
name: string;
description: string;
environmentId: string;
repository: string;
branch: string;
tag: string;
sha: string;
targetRepository: string;
targetBranch: string;
targetTag: string;
targetSha: string;
workflow: string;
usesSeparateWorkflowSource: boolean;
workflowSourceRepository: string;
workflowSourceKind: AutomationGitWorkflowSourceKind;
workflowSourceRef: string;
manualEnabled: boolean;
scheduleEnabled: boolean;
cron: string;
}
export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
id: "",
name: "",
description: "",
id: "",
name: "",
description: "",
environmentId: "",
repository: "",
branch: "main",
tag: "",
sha: "",
workflow: "",
manualEnabled: true,
scheduleEnabled: false,
cron: "0 9 * * 1-5",
targetRepository: "",
targetBranch: "main",
targetTag: "",
targetSha: "",
workflow: "",
usesSeparateWorkflowSource: false,
workflowSourceRepository: "",
workflowSourceKind: "branch",
workflowSourceRef: "",
manualEnabled: true,
scheduleEnabled: false,
cron: "0 9 * * 1-5",
};
const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
@ -62,19 +72,24 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
const apiTrigger = findApiTrigger(automation);
const scheduleTrigger = findScheduleTrigger(automation);
const target = gitTarget(automation.target);
const workflowSource = automation.workflow_source;
return {
id: automation.id,
name: automation.name,
description: automation.description ?? "",
id: automation.id,
name: automation.name,
description: automation.description ?? "",
environmentId: automation.environment_id ?? "",
repository: target?.repo ?? "",
branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch,
tag: target?.tag ?? "",
sha: target?.sha ?? "",
workflow: automation.workflow,
manualEnabled: apiTrigger?.enabled ?? false,
scheduleEnabled: scheduleTrigger?.enabled ?? false,
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
targetRepository: target?.repo ?? "",
targetBranch: target?.branch ?? EMPTY_AUTOMATION_FORM.targetBranch,
targetTag: target?.tag ?? "",
targetSha: target?.sha ?? "",
workflow: automation.workflow,
usesSeparateWorkflowSource: workflowSource != null,
workflowSourceRepository: workflowSource?.repo ?? "",
workflowSourceKind: workflowSource?.kind ?? "branch",
workflowSourceRef: workflowSource?.ref ?? "",
manualEnabled: apiTrigger?.enabled ?? false,
scheduleEnabled: scheduleTrigger?.enabled ?? false,
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
};
}
@ -97,7 +112,7 @@ export function automationFormValuesFromRun(
name,
);
const canonicalTarget = gitTarget(runState?.spec.target);
const repository = canonicalTarget?.repo
const targetRepository = canonicalTarget?.repo
?? githubRepositoryFromSettings(settings)
?? githubRepositoryName(run.repository?.name)
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
@ -112,16 +127,16 @@ export function automationFormValuesFromRun(
: "";
return {
...EMPTY_AUTOMATION_FORM,
id: kebabify(name),
id: kebabify(name),
name,
environmentId,
repository,
branch: canonicalTarget?.branch
targetRepository,
targetBranch: canonicalTarget?.branch
?? cloneBranch
?? EMPTY_AUTOMATION_FORM.branch,
tag: canonicalTarget?.tag ?? "",
sha: canonicalTarget?.sha ?? "",
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
?? EMPTY_AUTOMATION_FORM.targetBranch,
targetTag: canonicalTarget?.tag ?? "",
targetSha: canonicalTarget?.sha ?? "",
workflow: run.workflow.slug?.trim() || kebabify(workflowName),
};
}
@ -146,10 +161,11 @@ export function isFormValid(values: AutomationFormValues): boolean {
values.id.trim() !== "" &&
values.name.trim() !== "" &&
values.environmentId.trim() !== "" &&
values.repository.trim() !== "" &&
values.branch.trim() !== "" &&
isOptionalShaValid(values.sha) &&
values.workflow.trim() !== ""
values.targetRepository.trim() !== "" &&
values.targetBranch.trim() !== "" &&
isOptionalShaValid(values.targetSha) &&
values.workflow.trim() !== "" &&
isWorkflowSourceValid(values)
);
}
@ -165,10 +181,32 @@ function isOptionalShaValid(sha: string): boolean {
export function targetFromFormValues(values: AutomationFormValues): GitRunTarget {
return {
kind: "git",
repo: values.repository.trim(),
branch: values.branch.trim(),
tag: values.tag.trim() || undefined,
sha: values.sha.trim().toLowerCase() || undefined,
repo: values.targetRepository.trim(),
branch: values.targetBranch.trim(),
tag: values.targetTag.trim() || undefined,
sha: values.targetSha.trim().toLowerCase() || undefined,
};
}
function isWorkflowSourceValid(values: AutomationFormValues): boolean {
if (!values.usesSeparateWorkflowSource) return true;
const reference = values.workflowSourceRef.trim();
return (
values.workflowSourceRepository.trim() !== "" &&
reference !== "" &&
(values.workflowSourceKind !== "commit" || GIT_SHA_RE.test(reference))
);
}
export function workflowSourceFromFormValues(
values: AutomationFormValues,
): AutomationGitWorkflowSource | undefined {
if (!values.usesSeparateWorkflowSource) return undefined;
const reference = values.workflowSourceRef.trim();
return {
repo: values.workflowSourceRepository.trim(),
kind: values.workflowSourceKind,
ref: values.workflowSourceKind === "commit" ? reference.toLowerCase() : reference,
};
}
@ -238,6 +276,36 @@ function describeCron(expression: string): string {
return "Computed when saved";
}
function workflowSourceRefLabel(kind: AutomationGitWorkflowSourceKind): string {
switch (kind) {
case "branch": return "Branch";
case "tag": return "Tag";
case "commit": return "Exact commit";
}
}
function workflowSourceRefPlaceholder(kind: AutomationGitWorkflowSourceKind): string {
switch (kind) {
case "branch": return "main";
case "tag": return "v1.2.3";
case "commit": return "0123456789abcdef0123456789abcdef01234567";
}
}
function workflowSourceRefHelp(
kind: AutomationGitWorkflowSourceKind,
valid: boolean,
): ReactNode {
if (kind === "commit") {
return valid
? "Exactly 40 hexadecimal characters; the same workflow bytes are used every time."
: <span className="text-coral">Enter exactly 40 hexadecimal characters.</span>;
}
return kind === "branch"
? "Bare branch name resolved again whenever the automation fires."
: "Bare tag name resolved again whenever the automation fires.";
}
interface AutomationFormFieldsProps {
values: AutomationFormValues;
onChange: (values: AutomationFormValues) => void;
@ -256,7 +324,10 @@ export function AutomationFormFields({
environmentsError = false,
}: AutomationFormFieldsProps) {
const slugTouchedRef = useRef(values.id.length > 0);
const shaValid = isOptionalShaValid(values.sha);
const shaValid = isOptionalShaValid(values.targetSha);
const workflowSourceRefValid = values.workflowSourceKind !== "commit"
? values.workflowSourceRef.trim() !== ""
: GIT_SHA_RE.test(values.workflowSourceRef.trim());
const compatibleEnvironments = environments
.filter(isCloneBasedEnvironment)
.sort((left, right) => left.id.localeCompare(right.id));
@ -380,14 +451,17 @@ export function AutomationFormFields({
</Row>
</Panel>
<Panel title="Source">
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
<Panel title="Run target">
<Row
title={<Label required>Repository</Label>}
help="GitHub repository whose workspace the run changes, in owner/repo form."
>
<input
type="text"
name="repository"
aria-label="Repository"
value={values.repository}
onChange={(e) => patch({ repository: e.target.value })}
name="target_repository"
aria-label="Run target repository"
value={values.targetRepository}
onChange={(e) => patch({ targetRepository: e.target.value })}
placeholder="acme/orders-api"
autoComplete="off"
spellCheck={false}
@ -400,10 +474,10 @@ export function AutomationFormFields({
>
<input
type="text"
name="branch"
name="target_branch"
aria-label="Working branch"
value={values.branch}
onChange={(e) => patch({ branch: e.target.value })}
value={values.targetBranch}
onChange={(e) => patch({ targetBranch: e.target.value })}
placeholder="main"
autoComplete="off"
spellCheck={false}
@ -416,10 +490,10 @@ export function AutomationFormFields({
>
<input
type="text"
name="tag"
name="target_tag"
aria-label="Tag"
value={values.tag}
onChange={(e) => patch({ tag: e.target.value })}
value={values.targetTag}
onChange={(e) => patch({ targetTag: e.target.value })}
placeholder="v1.2.3"
autoComplete="off"
spellCheck={false}
@ -436,20 +510,27 @@ export function AutomationFormFields({
>
<input
type="text"
name="sha"
name="target_sha"
aria-label="Exact commit SHA"
aria-invalid={!shaValid}
value={values.sha}
onChange={(e) => patch({ sha: e.target.value })}
value={values.targetSha}
onChange={(e) => patch({ targetSha: e.target.value })}
placeholder="0123456789abcdef0123456789abcdef01234567"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
<Panel title="Workflow">
<Row
title={<Label required>Workflow slug</Label>}
help="Dash-separated identifier matching the workflow directory name (e.g. patch-cves)."
help={
values.usesSeparateWorkflowSource
? "Dash-separated identifier resolved in the workflow source checkout."
: "Dash-separated identifier resolved in the run target checkout."
}
>
<input
type="text"
@ -463,6 +544,71 @@ export function AutomationFormFields({
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title="Different repository"
help="Load workflow files from another saved GitHub coordinate while keeping the run target independent."
>
<ToggleSwitch
checked={values.usesSeparateWorkflowSource}
onChange={(usesSeparateWorkflowSource) => patch({ usesSeparateWorkflowSource })}
label="Use a different workflow repository"
/>
</Row>
{values.usesSeparateWorkflowSource ? (
<>
<Row
title={<Label required>Source repository</Label>}
help="GitHub owner/repo containing the workflow files."
>
<input
type="text"
name="workflow_source_repository"
aria-label="Workflow source repository"
value={values.workflowSourceRepository}
onChange={(e) => patch({ workflowSourceRepository: e.target.value })}
placeholder="acme/automation-workflows"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label required>Source kind</Label>}
help="Choose how Fabro interprets the source ref on every firing."
>
<select
name="workflow_source_kind"
aria-label="Workflow source kind"
value={values.workflowSourceKind}
onChange={(e) => patch({
workflowSourceKind: e.target.value as AutomationGitWorkflowSourceKind,
})}
className={`${INPUT_CLASS} font-mono`}
>
<option value="branch">Branch</option>
<option value="tag">Tag</option>
<option value="commit">Exact commit</option>
</select>
</Row>
<Row
title={<Label required>{workflowSourceRefLabel(values.workflowSourceKind)}</Label>}
help={workflowSourceRefHelp(values.workflowSourceKind, workflowSourceRefValid)}
>
<input
type="text"
name="workflow_source_ref"
aria-label="Workflow source ref"
aria-invalid={!workflowSourceRefValid}
value={values.workflowSourceRef}
onChange={(e) => patch({ workflowSourceRef: e.target.value })}
placeholder={workflowSourceRefPlaceholder(values.workflowSourceKind)}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</>
) : null}
</Panel>
<Panel title="Triggers">

View file

@ -1,4 +1,9 @@
import type { Automation, AutomationTrigger, RunTarget } from "@qltysh/fabro-api-client";
import type {
Automation,
AutomationGitWorkflowSource,
AutomationTrigger,
RunTarget,
} from "@qltysh/fabro-api-client";
export type GitRunTarget = Extract<RunTarget, { kind: "git" }>;
@ -27,3 +32,7 @@ export function findScheduleTrigger(
export function hasEnabledApiTrigger(automation: Automation): boolean {
return findApiTrigger(automation)?.enabled === true;
}
export function workflowSourceSummary(source: AutomationGitWorkflowSource): string {
return `${source.repo} · ${source.kind} ${source.ref}`;
}

View file

@ -24,6 +24,7 @@ import {
findApiTrigger,
findScheduleTrigger,
gitTarget,
workflowSourceSummary,
} from "../lib/automation";
import { useAutomation, useAutomationRuns } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
@ -100,6 +101,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
const scheduleTrigger = findScheduleTrigger(automation);
const apiTrigger = findApiTrigger(automation);
const target = gitTarget(automation.target);
const workflowSource = automation.workflow_source;
const canRun = apiTrigger?.enabled === true && automation.environment_id !== null;
async function onRun() {
@ -146,7 +148,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<Chip icon={FolderIcon}>
{target?.repo ?? UNSUPPORTED_TARGET_LABEL}
Run target · {target?.repo ?? UNSUPPORTED_TARGET_LABEL}
{target ? (
<span className="text-fg-muted/70">
{" · "}{target.branch}
@ -155,7 +157,11 @@ function AutomationHeader({ automation }: { automation: Automation }) {
</span>
) : null}
</Chip>
<Chip icon={RectangleStackIcon}>{automation.workflow}</Chip>
<Chip icon={RectangleStackIcon}>
Workflow · {automation.workflow} · {workflowSource
? workflowSourceSummary(workflowSource)
: "run target checkout"}
</Chip>
<Chip icon={CubeTransparentIcon}>
{automation.environment_id ?? (
<span className="text-coral">Environment required</span>

View file

@ -13,6 +13,7 @@ import {
isFormValid,
targetFromFormValues,
triggersFromFormValues,
workflowSourceFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
import { Panel, PanelSkeleton } from "../components/settings-panel";
@ -109,6 +110,7 @@ function EditAutomationForm({
environment_id: values.environmentId.trim(),
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
workflow_source: workflowSourceFromFormValues(values),
triggers: triggersFromFormValues(values),
}),
);

View file

@ -302,7 +302,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Automation slug")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Run target repository")).toBe("");
expect(fieldValue(renderer, "Working branch")).toBe("main");
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
@ -310,6 +310,8 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation environment")).toBe("");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(switchChecked(renderer, "Use a different workflow repository")).toBe(false);
expect(renderer.root.findAllByProps({ "aria-label": "Workflow source repository" })).toHaveLength(0);
});
test("environment selector offers Docker and Daytona but not local", async () => {
@ -340,7 +342,7 @@ describe("AutomationsNew", () => {
test("creation sends the selected environment id", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
changeField(renderer, "Automation name", "Nightly");
changeField(renderer, "Repository", "fabro-sh/fabro");
changeField(renderer, "Run target repository", "fabro-sh/fabro");
changeField(renderer, "Workflow slug", "hello");
changeField(renderer, "Automation environment", "daytona-smoke");
@ -372,7 +374,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
expect(fieldValue(renderer, "Run target repository")).toBe("qltysh/fabro");
expect(fieldValue(renderer, "Working branch")).toBe("feature/from-run");
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
@ -380,6 +382,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Automation environment")).toBe("default");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(switchChecked(renderer, "Use a different workflow repository")).toBe(false);
expect(
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
).toHaveLength(0);
@ -405,7 +408,7 @@ describe("AutomationsNew", () => {
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
expect(fieldValue(renderer, "Repository")).toBe("canonical/repo");
expect(fieldValue(renderer, "Run target repository")).toBe("canonical/repo");
expect(fieldValue(renderer, "Working branch")).toBe("release");
expect(fieldValue(renderer, "Tag")).toBe("v2.0.0");
expect(fieldValue(renderer, "Exact commit SHA")).toBe(
@ -436,8 +439,37 @@ describe("AutomationsNew", () => {
expect(textFromNode(renderer.toJSON())).toContain("could not be loaded");
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Run target repository")).toBe("");
expect(fieldValue(renderer, "Working branch")).toBe("main");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
});
test("submits a canonical explicit workflow source", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
changeField(renderer, "Automation name", "Nightly");
changeField(renderer, "Run target repository", "fabro-sh/app");
changeField(renderer, "Automation environment", "daytona-smoke");
changeField(renderer, "Workflow slug", "release");
act(() => {
byLabel(renderer, "Use a different workflow repository").props.onChange(true);
});
changeField(renderer, "Workflow source repository", " fabro-sh/workflows ");
changeField(renderer, "Workflow source ref", "ABCDEF0123456789ABCDEF0123456789ABCDEF01");
act(() => {
byLabel(renderer, "Workflow source kind").props.onChange({ target: { value: "commit" } });
});
await act(async () => {
await renderer.root.findByType("form").props.onSubmit({ preventDefault() {} });
});
expect(createAutomationMock).toHaveBeenCalledTimes(1);
expect(createAutomationMock.mock.calls[0]?.[0]).toMatchObject({
workflow_source: {
repo: "fabro-sh/workflows",
kind: "commit",
ref: "abcdef0123456789abcdef0123456789abcdef01",
},
});
});
});

View file

@ -14,6 +14,7 @@ import {
isFormValid,
targetFromFormValues,
triggersFromFormValues,
workflowSourceFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
import {
@ -137,6 +138,7 @@ function AutomationCreateForm({
environment_id: values.environmentId.trim(),
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
workflow_source: workflowSourceFromFormValues(values),
triggers: triggersFromFormValues(values),
}),
);

View file

@ -23,6 +23,7 @@ import {
findScheduleTrigger,
gitTarget,
hasEnabledApiTrigger,
workflowSourceSummary,
} from "../lib/automation";
import { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
@ -55,6 +56,7 @@ interface AutomationRow {
workflow: string;
repository: string;
environmentId: string | null;
workflowSource?: string;
schedule?: string;
apiEnabled: boolean;
icon: ComponentType<{ className?: string }>;
@ -96,6 +98,9 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
workflow: a.workflow,
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
environmentId: a.environment_id,
workflowSource: a.workflow_source
? workflowSourceSummary(a.workflow_source)
: undefined,
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
@ -149,11 +154,14 @@ function AutomationCard({
)}
</div>
<p className="mt-1 text-xs text-fg-muted">
{automation.repository}
Run target · {automation.repository}
<span className={automation.environmentId ? "" : " text-coral"}>
{" · "}{automation.environmentId ?? "environment required"}
</span>
</p>
<p className="mt-0.5 truncate text-xs text-fg-muted">
Workflow source · {automation.workflowSource ?? "run target checkout"}
</p>
</div>
</Link>
@ -291,7 +299,8 @@ export default function Automations() {
(triggerFilter === "manual" && a.schedule == null)) &&
(a.name.toLowerCase().includes(lowerQuery) ||
a.workflow.toLowerCase().includes(lowerQuery) ||
a.repository.toLowerCase().includes(lowerQuery)),
a.repository.toLowerCase().includes(lowerQuery) ||
a.workflowSource?.toLowerCase().includes(lowerQuery)),
);
async function confirmDelete() {

View file

@ -6728,6 +6728,43 @@ components:
# ── Automations ──────────────────────────────────────────────────────
AutomationGitWorkflowSourceKind:
description: How an automation interprets the workflow source `ref`.
type: string
enum: [branch, tag, commit]
AutomationGitWorkflowSource:
description: >-
Explicit GitHub coordinate from which an automation acquires workflow
bytes. The kind makes `ref` unambiguous; this source is independent of
the run target and does not provide a working branch for the run.
type: object
additionalProperties: false
required:
- repo
- kind
- ref
properties:
repo:
type: string
minLength: 3
maxLength: 140
pattern: "^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$"
description: GitHub repository slug in `owner/name` form.
example: acme/workflows
kind:
$ref: "#/components/schemas/AutomationGitWorkflowSourceKind"
ref:
type: string
minLength: 1
maxLength: 255
pattern: "^[A-Za-z0-9/._-]+$"
description: >-
Bare branch or tag name, or an exact 40-character commit SHA, as
selected by `kind`. Prefixes such as `refs/heads/` and `refs/tags/`
are not accepted.
example: main
Automation:
description: Public automation definition.
type: object
@ -6772,8 +6809,13 @@ components:
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
description: >-
Workflow slug or path resolved in the run-target checkout when
`workflow_source` is omitted, or in the explicit workflow-source
checkout when present.
example: dependency-update
workflow_source:
$ref: "#/components/schemas/AutomationGitWorkflowSource"
triggers:
type: array
items:
@ -6867,8 +6909,13 @@ components:
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
description: >-
Workflow slug or path resolved in the run-target checkout when
`workflow_source` is omitted, or in the explicit workflow-source
checkout when present.
example: dependency-update
workflow_source:
$ref: "#/components/schemas/AutomationGitWorkflowSource"
triggers:
type: array
items:
@ -6899,8 +6946,13 @@ components:
$ref: "#/components/schemas/RunTarget"
workflow:
type: string
description: Workflow slug or path resolved in the selected repository checkout.
description: >-
Workflow slug or path resolved in the run-target checkout when
`workflow_source` is omitted, or in the explicit workflow-source
checkout when present.
example: dependency-update
workflow_source:
$ref: "#/components/schemas/AutomationGitWorkflowSource"
triggers:
type: array
items:

View file

@ -3,7 +3,7 @@ title: "Automations"
description: "Named, repeatable run configurations with API and schedule triggers"
---
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, workflow, and server-managed environment — plus the triggers that may start it. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
An **automation** is a saved run configuration — a Git run target, a workflow, a server-managed environment, and the triggers that may start it. By default, Fabro loads the workflow from the run-target checkout. An automation can instead name an independent GitHub repository and branch, tag, or exact commit for its workflow files. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
## Defining automations
@ -45,6 +45,38 @@ An extensionless workflow such as `"release"` resolves directly to `.fabro/workf
Automation admission does not read `.fabro/project.toml`. Put settings needed by the run in the workflow configuration or the selected server environment. Fabro packages the workflow and its runnable dependencies into immutable workflow versions before creating the run.
### Using a separate workflow repository
Omit `workflow_source` to resolve the `workflow` selector in the run-target checkout, as in the request above. This is the compatibility default for existing definitions.
To keep reusable workflow files in another repository, provide an explicit source with one unambiguous ref kind:
```json title="Create automation with a separate workflow source"
{
"name": "Nightly release",
"target": {
"kind": "git",
"repo": "acme/orders-api",
"branch": "main"
},
"workflow": "release",
"workflow_source": {
"repo": "acme/automation-workflows",
"kind": "branch",
"ref": "main"
},
"triggers": [
{ "type": "api", "id": "manual", "enabled": true }
]
}
```
`kind` may be `branch`, `tag`, or `commit`. Branch and tag refs are bare names and are resolved again on every firing. A commit ref is exactly 40 hexadecimal characters and always selects that commit. The server uses its configured GitHub credentials independently for the target and workflow-source repositories; automation requests never carry credentials.
Fabro resolves the run target to an exact commit and checks out the selected workflow source before it creates a run. It packages the workflow and its dependencies into immutable, content-addressed workflow versions, then admits the run with the root workflow-version ID and the independently exact run target. The mutable source coordinate is therefore resolved per firing, while the bytes used by that run remain pinned. If an explicit source names the same repository and effective selector as the target, Fabro reuses the checkout without removing the explicit saved source.
If target or source authentication, checkout, workflow discovery, packaging, or workflow-version storage fails, Fabro creates no run and sends no start request.
### Upgrading legacy targets
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair.
@ -86,7 +118,7 @@ The same conversion runs transactionally for automations already in SQLite. An u
Automations created before environment selection was introduced are backfilled conservatively. Fabro selects a compatible environment named `default` when one exists, or the sole Docker or Daytona environment when there is exactly one. With no compatible environment or multiple ambiguous choices, the automation remains incomplete until an operator selects one in the web UI. An incomplete automation cannot run.
When a trigger fires, Fabro prepares the repository at the selected branch, tag, or exact commit, packages the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
When a trigger fires, Fabro resolves the run target and selected workflow checkout, packages the workflow, and creates and starts the run. The created run records the exact checked-out target commit in its canonical target, so later inspection and automation creation preserve the target revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
## Triggers
@ -114,7 +146,7 @@ The server fires each enabled schedule trigger at its next occurrence and create
The `/automations` area lists automations with create, edit, delete, and Run actions. The create and edit forms require a Docker or Daytona environment. Migrated automations without an environment are shown as incomplete and cannot run until edited. Saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting each other. The detail page shows the automation's configuration, its most recent schedule error, and its run history with status, time, and repo filters.
To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's repository and workflow. Runs that were created by an automation show **View automation** instead.
To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's target repository and workflow. Its workflow source defaults to the target checkout because normal run summaries do not retain the automation's mutable source coordinate. You can select a separate source before saving. Runs that were created by an automation show **View automation** instead.
## API

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_automation::{AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind};
use fabro_store::KeyedMutex;
use fabro_types::{GitHubRepositorySlug, GitRunTarget};
use tokio::process::Command;
@ -122,7 +123,7 @@ impl GitRepoCache {
self.prepare_worktree_with_clone_url(args, &clone_url).await
}
async fn prepare_worktree_with_clone_url(
pub(crate) async fn prepare_worktree_with_clone_url(
&self,
args: WorktreePrepareInput<'_>,
clone_url: &str,
@ -179,7 +180,7 @@ impl GitRepoCache {
.map_err(|source| GitCheckoutError::Clone { source })?;
}
let fetch_target = GitFetchTarget::from(args.target);
let fetch_target = args.selector;
run_git_plan(build_bare_fetch_plan(
bare_dir,
clone_url,
@ -202,18 +203,19 @@ impl GitRepoCache {
pub(crate) struct WorktreePrepareInput<'a> {
pub repo: &'a GitHubRepositorySlug,
pub target: &'a GitRunTarget,
pub selector: GitCheckoutSelector<'a>,
pub auth: Option<&'a GitAuthConfig>,
pub worktree_dir: &'a Path,
}
enum GitFetchTarget<'a> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GitCheckoutSelector<'a> {
Branch(&'a str),
Tag(&'a str),
Commit(&'a str),
}
impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
impl<'a> From<&'a GitRunTarget> for GitCheckoutSelector<'a> {
fn from(target: &'a GitRunTarget) -> Self {
if let Some(sha) = target.sha.as_deref() {
Self::Commit(sha)
@ -225,7 +227,17 @@ impl<'a> From<&'a GitRunTarget> for GitFetchTarget<'a> {
}
}
impl GitFetchTarget<'_> {
impl<'a> From<&'a AutomationGitWorkflowSource> for GitCheckoutSelector<'a> {
fn from(source: &'a AutomationGitWorkflowSource) -> Self {
match source.kind {
AutomationGitWorkflowSourceKind::Branch => Self::Branch(&source.reference),
AutomationGitWorkflowSourceKind::Tag => Self::Tag(&source.reference),
AutomationGitWorkflowSourceKind::Commit => Self::Commit(&source.reference),
}
}
}
impl GitCheckoutSelector<'_> {
fn selector(&self) -> Cow<'_, str> {
match self {
Self::Branch(selector) | Self::Commit(selector) => Cow::Borrowed(selector),
@ -281,7 +293,7 @@ pub(crate) struct GitAuthConfig {
}
impl GitAuthConfig {
fn new(username: Option<String>, password: Option<String>) -> Self {
pub(crate) fn new(username: Option<String>, password: Option<String>) -> Self {
let Some(password) = password.filter(|value| !value.is_empty()) else {
return Self {
extraheader: None,
@ -558,6 +570,7 @@ mod tests {
use std::fs;
use std::path::Path;
use fabro_automation::{AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind};
use tempfile::TempDir;
use super::*;
@ -575,6 +588,70 @@ mod tests {
}
}
fn workflow_source(
kind: AutomationGitWorkflowSourceKind,
reference: &str,
) -> AutomationGitWorkflowSource {
AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind,
reference: reference.to_string(),
}
}
#[test]
fn checkout_selectors_preserve_target_precedence_and_source_kind() {
let target = git_target(
"main",
Some("v1"),
Some("abcdef0123456789abcdef0123456789abcdef01"),
);
assert_eq!(
GitCheckoutSelector::from(&target),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
);
for (source, expected) in [
(
workflow_source(AutomationGitWorkflowSourceKind::Branch, "main"),
GitCheckoutSelector::Branch("main"),
),
(
workflow_source(AutomationGitWorkflowSourceKind::Tag, "v1"),
GitCheckoutSelector::Tag("v1"),
),
(
workflow_source(
AutomationGitWorkflowSourceKind::Commit,
"abcdef0123456789abcdef0123456789abcdef01",
),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
),
] {
assert_eq!(GitCheckoutSelector::from(&source), expected);
}
}
#[test]
fn checkout_reuse_identity_folds_only_repository_case() {
assert_eq!(
repository_slug("Fabro-Sh/Workflows"),
repository_slug("fabro-sh/workflows")
);
assert_ne!(
GitCheckoutSelector::Branch("Main"),
GitCheckoutSelector::Branch("main")
);
assert_ne!(
GitCheckoutSelector::Branch("v1"),
GitCheckoutSelector::Tag("v1")
);
assert_eq!(
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
);
}
#[test]
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
let repo = repository_slug("fabro-sh/fabro");
@ -810,7 +887,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_a,
},
@ -832,7 +909,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_b,
},
@ -863,7 +940,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_a,
},
@ -881,7 +958,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_b,
},
@ -919,7 +996,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &target,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &temp.path().join(name),
},
@ -947,7 +1024,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &missing_tag,
selector: GitCheckoutSelector::from(&missing_tag),
auth: None,
worktree_dir: &temp.path().join("missing-tag"),
},
@ -964,7 +1041,7 @@ mod tests {
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
target: &unavailable_commit,
selector: GitCheckoutSelector::from(&unavailable_commit),
auth: None,
worktree_dir: &temp.path().join("missing-commit"),
},

View file

@ -1205,7 +1205,7 @@ impl AppState {
let credentials = self
.github_credentials(&settings.server.integrations.github)
.await
.map_err(|source| RunMaterializeError::Credentials { source })?;
.map_err(|source| RunMaterializeError::LoadCredentials { source })?;
ProductionAutomationRunMaterializer::new(
credentials,
self.github_api_base_url.clone(),

View file

@ -263,6 +263,7 @@ async fn fire_scheduled_automation_run(
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation_id.clone(),
target,
workflow_source: automation.workflow_source.clone(),
workflow: automation.workflow.clone(),
run_id,
temp_root: state.automation_temp_root(),
@ -388,7 +389,10 @@ fn run_due_schedules_once<'a>(
#[cfg(test)]
mod tests {
use fabro_automation::{AutomationDraft, AutomationTrigger, ScheduleTrigger};
use fabro_automation::{
AutomationDraft, AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind,
AutomationTrigger, ScheduleTrigger,
};
use fabro_static::EnvVars;
use fabro_store::ListRunsQuery;
use fabro_types::{GitRunTarget, RunStatus, RunTarget};
@ -432,6 +436,7 @@ mod tests {
environment_id: Some("default".to_string()),
last_error: None,
target: target(),
workflow_source: None,
workflow: "workflow.fabro".to_string(),
triggers,
}
@ -451,6 +456,29 @@ mod tests {
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow_source: None,
workflow: "workflow.fabro".to_string(),
triggers,
})
.await
.expect("test automation should be created")
}
async fn create_automation_with_source(
state: &AppState,
id: &str,
workflow_source: AutomationGitWorkflowSource,
triggers: Vec<AutomationTrigger>,
) -> Automation {
state
.automation_store()
.create(AutomationDraft {
id: AutomationId::new(id).expect("test automation id should be valid"),
name: id.to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow_source: Some(workflow_source),
workflow: "workflow.fabro".to_string(),
triggers,
})
@ -701,6 +729,33 @@ mod tests {
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
}
#[tokio::test]
async fn scheduled_run_passes_saved_workflow_source_to_materialization() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer.clone());
let workflow_source = AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Commit,
reference: "0123456789abcdef0123456789abcdef01234567".to_string(),
};
create_automation_with_source(
state.as_ref(),
"scheduled-source",
workflow_source.clone(),
vec![schedule_trigger("schedule", "* * * * *", true)],
)
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
let captured = materializer.captured_inputs();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].workflow_source, Some(workflow_source));
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
}
#[tokio::test]
async fn disabled_schedule_trigger_does_not_create_run() {
let materializer = succeeding_materializer();
@ -802,4 +857,28 @@ mod tests {
assert!(cached_runs(state.as_ref()).await.is_empty());
assert_eq!(materializer.captured_inputs().len(), 2);
}
#[tokio::test]
async fn workflow_source_failure_creates_and_starts_no_scheduled_run() {
let materializer = TestAutomationRunMaterializer::fail_invalid_workflow_source();
let state = test_state_with_materializer(materializer.clone());
create_automation_with_source(
state.as_ref(),
"failing-source",
AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Branch,
reference: "main".to_string(),
},
vec![schedule_trigger("schedule", "* * * * *", true)],
)
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
assert!(cached_runs(state.as_ref()).await.is_empty());
assert_eq!(materializer.captured_inputs().len(), 1);
}
}

View file

@ -140,6 +140,7 @@ async fn create_automation_run(
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation.id.clone(),
target,
workflow_source: automation.workflow_source.clone(),
workflow: automation.workflow.clone(),
run_id,
temp_root: state.automation_temp_root(),

View file

@ -4949,6 +4949,7 @@ async fn fake_automation_materializer_injection_captures_input_and_returns_versi
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: AutomationId::new("nightly").unwrap(),
target: target.clone(),
workflow_source: None,
workflow: "demo".to_string(),
run_id,
temp_root: temp_root.clone(),

View file

@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_automation::{AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind};
use fabro_config::Storage;
use fabro_server::server::build_router;
use fabro_server::test_support::{
@ -1034,6 +1035,37 @@ async fn incomplete_legacy_automation_fails_before_materialization() {
);
}
#[tokio::test]
async fn api_triggered_run_passes_saved_workflow_source_to_materialization() {
let materializer = TestAutomationRunMaterializer::succeed(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
});
let (app, _temp_dir, _automation_dir) = automation_app_with_materializer(materializer.clone());
let mut body = automation_body("nightly", "Nightly");
body["workflow_source"] = json!({
"repo": "fabro-sh/workflows",
"kind": "tag",
"ref": "release-v1"
});
create_automation_with_body(&app, &body).await;
create_automation_run(&app, "nightly", StatusCode::CREATED).await;
let captured = materializer.captured_workflow_sources();
assert_eq!(captured.len(), 1);
assert_eq!(
captured[0],
Some(AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind: AutomationGitWorkflowSourceKind::Tag,
reference: "release-v1".to_string(),
})
);
}
#[tokio::test]
async fn api_triggered_automation_with_missing_version_does_not_create_or_start_a_run() {
let materializer = TestAutomationRunMaterializer::return_unstored_version(GitRunTarget {
@ -1053,6 +1085,30 @@ async fn api_triggered_automation_with_missing_version_does_not_create_or_start_
assert_eq!(runs["data"], json!([]));
}
#[tokio::test]
async fn api_workflow_source_failure_does_not_create_or_start_a_run() {
let materializer = TestAutomationRunMaterializer::fail_invalid_workflow_source();
let (app, _temp_dir, _automation_dir) = automation_app_with_materializer(materializer);
let mut body = automation_body("nightly", "Nightly");
body["workflow_source"] = json!({
"repo": "fabro-sh/workflows",
"kind": "branch",
"ref": "main"
});
create_automation_with_body(&app, &body).await;
let error = create_automation_run(&app, "nightly", StatusCode::UNPROCESSABLE_ENTITY).await;
assert!(
error["errors"][0]["detail"]
.as_str()
.is_some_and(|detail| detail.contains("workflow source"))
);
let runs = list_automation_runs(&app, "/automations/nightly/runs").await;
assert_eq!(runs["meta"]["total"], 0);
assert_eq!(runs["data"], json!([]));
}
#[tokio::test]
async fn automation_run_listing_includes_only_runs_for_that_automation() {
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();

View file

@ -21,6 +21,7 @@ hex.workspace = true
serde.workspace = true
sha2.workspace = true
sqlx.workspace = true
strum.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
@ -28,5 +29,6 @@ tracing.workspace = true
[dev-dependencies]
anyhow.workspace = true
serde_json.workspace = true
tempfile = "3"
tokio = { workspace = true, features = ["macros", "test-util"] }

View file

@ -108,6 +108,7 @@ fn parse_legacy_automation(
environment_id: None,
target,
workflow,
workflow_source: None,
triggers: legacy.triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation { id, source })

View file

@ -62,12 +62,13 @@ pub async fn backfill_environment_selectors(
for automation in &incomplete {
store
.replace(&automation.id, &automation.revision, AutomationReplace {
name: automation.name.clone(),
description: automation.description.clone(),
environment_id: Some(environment_id.clone()),
target: automation.target.clone(),
workflow: automation.workflow.clone(),
triggers: automation.triggers.clone(),
name: automation.name.clone(),
description: automation.description.clone(),
environment_id: Some(environment_id.clone()),
target: automation.target.clone(),
workflow: automation.workflow.clone(),
workflow_source: automation.workflow_source.clone(),
triggers: automation.triggers.clone(),
})
.await?;
}

View file

@ -1,7 +1,7 @@
use std::path::PathBuf;
use croner::errors::CronError;
use fabro_types::TargetValidationError;
use fabro_types::{GitHubRepositorySlugError, TargetValidationError};
use toml::de::Error as TomlDeError;
use toml::ser::Error as TomlSerError;
@ -24,6 +24,17 @@ pub enum AutomationValidationError {
#[source]
source: TargetValidationError,
},
#[error("automation workflow source repository must be a valid GitHub owner/name slug")]
InvalidWorkflowSourceRepository {
#[source]
source: GitHubRepositorySlugError,
},
#[error("automation workflow source branch must be a non-empty bare branch name")]
InvalidWorkflowSourceBranch,
#[error("automation workflow source tag must be a non-empty bare tag name")]
InvalidWorkflowSourceTag,
#[error("automation workflow source commit must be exactly 40 ASCII hexadecimal characters")]
InvalidWorkflowSourceCommit,
#[error("workflow selector {value:?} is not safe")]
InvalidWorkflowSelector { value: String },
#[error("duplicate automation trigger id {id:?}")]
@ -77,6 +88,15 @@ pub enum AutomationStoreError {
},
#[error("stored automation {id} has an invalid trigger row")]
StoredTriggerShape { id: AutomationId },
#[error("stored automation {id} has a partial workflow source coordinate")]
StoredWorkflowSourceShape { id: AutomationId },
#[error("stored automation {id} has unknown workflow source kind {kind:?}")]
StoredWorkflowSourceKind {
id: AutomationId,
kind: String,
#[source]
source: strum::ParseError,
},
#[error("stored automation {id} has an invalid revision")]
InvalidRevision {
id: AutomationId,
@ -163,6 +183,8 @@ impl AutomationStoreError {
Self::StoredValidation { .. } => "stored_validation",
Self::StoredId { .. } => "stored_id",
Self::StoredTriggerShape { .. } => "stored_trigger_shape",
Self::StoredWorkflowSourceShape { .. } => "stored_workflow_source_shape",
Self::StoredWorkflowSourceKind { .. } => "stored_workflow_source_kind",
Self::InvalidRevision { .. } => "invalid_revision",
Self::Db { .. } => "db",
Self::InvalidFilename { .. } => "invalid_filename",

View file

@ -12,7 +12,8 @@ pub use migrations::{
import_legacy_directory_once,
};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger,
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind, AutomationReplace, AutomationTrigger, ScheduleTrigger,
parse_schedule_expression,
};
pub use store::AutomationStore;

View file

@ -4,7 +4,10 @@ use std::sync::LazyLock;
use croner::Cron;
use croner::errors::CronError;
use croner::parser::{CronParser, Seconds, Year};
use fabro_types::{GitRunTarget, RunTarget};
use fabro_types::{
GitHubRepositorySlug, GitRunTarget, RunTarget, is_valid_git_branch_name, is_valid_git_tag_name,
normalize_git_commit_sha,
};
use serde::{Deserialize, Serialize};
use crate::{
@ -34,19 +37,21 @@ pub fn parse_schedule_expression(expression: &str) -> Result<Cron, CronError> {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Automation {
pub id: AutomationId,
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
pub id: AutomationId,
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
/// Server-managed environment selected when the automation fires. Legacy
/// rows may be incomplete until an operator selects one.
pub environment_id: Option<String>,
pub environment_id: Option<String>,
/// Most recent scheduler failure. Runtime status is not part of the
/// optimistic-concurrency revision.
pub last_error: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub last_error: Option<String>,
pub target: RunTarget,
pub workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<AutomationGitWorkflowSource>,
pub triggers: Vec<AutomationTrigger>,
}
impl Automation {
@ -140,11 +145,77 @@ impl Automation {
last_error: None,
target: replace.target,
workflow: replace.workflow,
workflow_source: replace.workflow_source,
triggers: replace.triggers,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AutomationGitWorkflowSourceKind {
Branch,
Tag,
Commit,
}
impl AutomationGitWorkflowSourceKind {
#[must_use]
pub fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationGitWorkflowSource {
pub repo: String,
pub kind: AutomationGitWorkflowSourceKind,
#[serde(rename = "ref")]
pub reference: String,
}
impl AutomationGitWorkflowSource {
/// Validate and canonicalize this saved GitHub workflow coordinate without
/// resolving remote repository state.
pub fn validate(mut self) -> Result<Self, AutomationValidationError> {
self.repo
.parse::<GitHubRepositorySlug>()
.map_err(
|source| AutomationValidationError::InvalidWorkflowSourceRepository { source },
)?;
match self.kind {
AutomationGitWorkflowSourceKind::Branch => {
if !is_valid_git_branch_name(&self.reference) {
return Err(AutomationValidationError::InvalidWorkflowSourceBranch);
}
}
AutomationGitWorkflowSourceKind::Tag => {
if !is_valid_git_tag_name(&self.reference) {
return Err(AutomationValidationError::InvalidWorkflowSourceTag);
}
}
AutomationGitWorkflowSourceKind::Commit => {
self.reference = normalize_git_commit_sha(&self.reference)
.ok_or(AutomationValidationError::InvalidWorkflowSourceCommit)?;
}
}
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AutomationTrigger {
@ -200,26 +271,29 @@ pub struct ScheduleTrigger {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationDraft {
pub id: AutomationId,
pub name: String,
pub id: AutomationId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<AutomationGitWorkflowSource>,
pub triggers: Vec<AutomationTrigger>,
}
impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
fn from(value: AutomationDraft) -> Self {
(value.id, AutomationReplace {
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
workflow_source: value.workflow_source,
triggers: value.triggers,
})
}
}
@ -227,39 +301,44 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationReplace {
pub name: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<AutomationGitWorkflowSource>,
pub triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PersistedAutomation {
name: String,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
environment_id: Option<String>,
target: RunTarget,
workflow: String,
environment_id: Option<String>,
target: RunTarget,
workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_source: Option<AutomationGitWorkflowSource>,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
triggers: Vec<AutomationTrigger>,
}
impl From<AutomationReplace> for PersistedAutomation {
fn from(value: AutomationReplace) -> Self {
Self {
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
workflow_source: value.workflow_source,
triggers: value.triggers,
}
}
}
@ -267,12 +346,13 @@ impl From<AutomationReplace> for PersistedAutomation {
impl From<PersistedAutomation> for AutomationReplace {
fn from(value: PersistedAutomation) -> Self {
Self {
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
workflow_source: value.workflow_source,
triggers: value.triggers,
}
}
}
@ -321,6 +401,10 @@ fn normalize_replace(
.environment_id
.map(|environment_id| environment_id.trim().to_string())
.filter(|environment_id| !environment_id.is_empty());
value.workflow_source = value
.workflow_source
.map(normalize_workflow_source)
.transpose()?;
validate_fields(&value, require_environment)?;
let api_enabled = value
@ -358,6 +442,12 @@ fn normalize_replace(
Ok(value)
}
fn normalize_workflow_source(
source: AutomationGitWorkflowSource,
) -> Result<AutomationGitWorkflowSource, AutomationValidationError> {
source.validate()
}
fn validate_target(target: RunTarget) -> Result<RunTarget, AutomationValidationError> {
if !matches!(&target, RunTarget::Git(_)) {
return Err(AutomationValidationError::UnsupportedTarget {
@ -434,7 +524,8 @@ mod tests {
use fabro_types::{GitRunTarget, RunTarget, TargetValidationError};
use crate::{
ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTrigger,
ApiTrigger, Automation, AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind,
AutomationId, AutomationReplace, AutomationStoreError, AutomationTrigger,
AutomationTriggerId, AutomationValidationError, ScheduleTrigger,
};
@ -466,6 +557,181 @@ mod tests {
})
}
fn workflow_source(
kind: AutomationGitWorkflowSourceKind,
reference: &str,
) -> AutomationGitWorkflowSource {
AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind,
reference: reference.to_string(),
}
}
fn replace_with_source(
workflow_source: Option<AutomationGitWorkflowSource>,
) -> AutomationReplace {
AutomationReplace {
name: "Nightly".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source,
triggers: vec![api_trigger("manual")],
}
}
#[test]
fn omitted_workflow_source_preserves_canonical_bytes_and_revision() {
let expected = concat!(
"name = \"Nightly\"\n",
"environment_id = \"default\"\n",
"workflow = \"release\"\n",
"\n",
"[target]\n",
"kind = \"git\"\n",
"repo = \"fabro-sh/fabro\"\n",
"branch = \"main\"\n",
"\n",
"[[triggers]]\n",
"type = \"api\"\n",
"id = \"manual\"\n",
"enabled = true\n",
);
let (automation, bytes) = Automation::from_replace(
AutomationId::new("nightly").unwrap(),
replace_with_source(None),
)
.unwrap();
assert_eq!(automation.workflow_source, None);
assert_eq!(bytes, expected.as_bytes());
assert_eq!(
automation.revision.as_str(),
"bc26bacc9ed091f4f171c8fdaf45cd319549a50b4291a5c93205028094abf385"
);
let decoded =
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), expected.as_bytes())
.unwrap();
assert_eq!(decoded.workflow_source, None);
assert_eq!(
serde_json::to_value(decoded)
.unwrap()
.get("workflow_source"),
None
);
}
#[test]
fn workflow_sources_round_trip_and_commits_are_canonicalized() {
for (kind, reference, expected) in [
(AutomationGitWorkflowSourceKind::Branch, "main", "main"),
(
AutomationGitWorkflowSourceKind::Tag,
"release/v1",
"release/v1",
),
(
AutomationGitWorkflowSourceKind::Commit,
"ABCDEF0123456789ABCDEF0123456789ABCDEF01",
"abcdef0123456789abcdef0123456789abcdef01",
),
] {
let (automation, bytes) = Automation::from_replace(
AutomationId::new("nightly").unwrap(),
replace_with_source(Some(workflow_source(kind, reference))),
)
.unwrap();
let source = automation.workflow_source.as_ref().unwrap();
assert_eq!(source.kind, kind);
assert_eq!(source.reference, expected);
assert!(
String::from_utf8(bytes.clone())
.unwrap()
.contains("[workflow_source]")
);
let decoded =
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), &bytes).unwrap();
assert_eq!(decoded.workflow_source, automation.workflow_source);
}
}
#[test]
fn explicit_workflow_source_changes_revision_and_is_never_collapsed() {
let (omitted, _) = Automation::from_replace(
AutomationId::new("nightly").unwrap(),
replace_with_source(None),
)
.unwrap();
let mut explicit_source = workflow_source(AutomationGitWorkflowSourceKind::Branch, "main");
explicit_source.repo = "FABRO-SH/FABRO".to_string();
let (explicit, _) = Automation::from_replace(
AutomationId::new("nightly").unwrap(),
replace_with_source(Some(explicit_source.clone())),
)
.unwrap();
assert_ne!(explicit.revision, omitted.revision);
assert_eq!(explicit.workflow_source, Some(explicit_source));
}
#[test]
fn workflow_source_validation_reports_the_invalid_coordinate_part() {
let cases = [
(
workflow_source(AutomationGitWorkflowSourceKind::Branch, "main"),
"repo",
),
(
workflow_source(AutomationGitWorkflowSourceKind::Branch, "refs/heads/main"),
"branch",
),
(
workflow_source(AutomationGitWorkflowSourceKind::Tag, "tags/v1"),
"tag",
),
(
workflow_source(AutomationGitWorkflowSourceKind::Commit, "short"),
"commit",
),
];
for (mut source, expected_kind) in cases {
if expected_kind == "repo" {
source.repo = "not/a/github/slug".to_string();
}
let error = Automation::from_replace(
AutomationId::new("nightly").unwrap(),
replace_with_source(Some(source)),
)
.unwrap_err();
let AutomationStoreError::Validation { source } = error else {
panic!("expected validation error");
};
assert!(match expected_kind {
"repo" => matches!(
source,
AutomationValidationError::InvalidWorkflowSourceRepository { .. }
),
"branch" => matches!(
source,
AutomationValidationError::InvalidWorkflowSourceBranch
),
"tag" => matches!(source, AutomationValidationError::InvalidWorkflowSourceTag),
"commit" => matches!(
source,
AutomationValidationError::InvalidWorkflowSourceCommit
),
_ => false,
});
}
}
#[test]
fn persisted_toml_applies_defaults_and_canonicalizes_without_id_or_revision() {
let bytes = br#"
@ -530,12 +796,13 @@ enabled = true
fn enabled_schedule_triggers_returns_only_enabled_schedule_triggers() {
let (automation, _) =
Automation::from_replace(AutomationId::new("nightly").unwrap(), AutomationReplace {
name: "Nightly".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
triggers: vec![
name: "Nightly".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
workflow_source: None,
triggers: vec![
api_trigger("manual"),
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
schedule_trigger_with_enabled("disabled", "0 1 * * *", false),
@ -581,81 +848,89 @@ enabled = true
fn validation_rejects_invalid_inputs() {
let cases = [
AutomationReplace {
name: " ".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
name: " ".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad repo".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
name: "Bad repo".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
repo: "not/github/slug".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad ref".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
name: "Bad ref".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main;rm".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad workflow".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "../release".to_string(),
triggers: vec![api_trigger("manual")],
name: "Bad workflow".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "../release".to_string(),
workflow_source: None,
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Duplicate trigger".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
name: "Duplicate trigger".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![
api_trigger("manual"),
schedule_trigger("manual", "0 0 * * *"),
],
},
AutomationReplace {
name: "Two API triggers".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("one"), api_trigger("two")],
name: "Two API triggers".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![api_trigger("one"), api_trigger("two")],
},
AutomationReplace {
name: "Six field cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
name: "Six field cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
},
AutomationReplace {
name: "Bad cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
name: "Bad cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
},
];

View file

@ -6,7 +6,8 @@ use sqlx::sqlite::SqliteRow;
use sqlx::{Row as _, Sqlite, Transaction};
use crate::{
ApiTrigger, Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
ApiTrigger, Automation, AutomationDraft, AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind, AutomationId, AutomationReplace, AutomationRevision,
AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
};
@ -28,6 +29,9 @@ macro_rules! select_automations_sql {
a.target_tag,
a.target_sha,
a.target_workflow,
a.workflow_source_repository,
a.workflow_source_kind,
a.workflow_source_ref,
t.id AS trigger_id,
t.enabled AS trigger_enabled,
t.expression AS trigger_expression
@ -125,6 +129,7 @@ impl AutomationStore {
) -> Result<Automation, AutomationStoreError> {
let (automation, _) = Automation::from_replace(id.clone(), draft)?;
let target = stored_git_target(&automation);
let workflow_source = automation.workflow_source.as_ref();
let mut transaction = self.pool.begin().await?;
let result = sqlx::query(
r"
@ -139,7 +144,10 @@ impl AutomationStore {
target_branch = ?,
target_tag = ?,
target_sha = ?,
target_workflow = ?
target_workflow = ?,
workflow_source_repository = ?,
workflow_source_kind = ?,
workflow_source_ref = ?
WHERE id = ? AND revision = ?
",
)
@ -153,6 +161,9 @@ impl AutomationStore {
.bind(target.tag.as_deref())
.bind(target.sha.as_deref())
.bind(&automation.workflow)
.bind(workflow_source.map(|source| source.repo.as_str()))
.bind(workflow_source.map(|source| source.kind.as_str()))
.bind(workflow_source.map(|source| source.reference.as_str()))
.bind(id.as_str())
.bind(expected.as_str())
.execute(&mut *transaction)
@ -199,6 +210,7 @@ struct StoredAutomation {
api_enabled: bool,
target: RunTarget,
workflow: String,
workflow_source: Option<AutomationGitWorkflowSource>,
schedule_triggers: Vec<ScheduleTrigger>,
}
@ -216,6 +228,7 @@ impl StoredAutomation {
id: id.clone(),
source,
})?;
let workflow_source = stored_workflow_source(row, &id)?;
Ok(Self {
id,
revision,
@ -231,6 +244,7 @@ impl StoredAutomation {
sha: row.try_get("target_sha")?,
}),
workflow: row.try_get("target_workflow")?,
workflow_source,
schedule_triggers: Vec::new(),
})
}
@ -279,6 +293,7 @@ impl StoredAutomation {
environment_id: self.environment_id,
target: self.target,
workflow: self.workflow,
workflow_source: self.workflow_source,
triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation { id, source })?;
@ -324,6 +339,7 @@ pub(crate) async fn insert_automation_ignoring_conflict(
automation: &Automation,
) -> Result<bool, AutomationStoreError> {
let target = stored_git_target(automation);
let workflow_source = automation.workflow_source.as_ref();
let result = sqlx::query(
r"
INSERT INTO automations (
@ -337,8 +353,11 @@ pub(crate) async fn insert_automation_ignoring_conflict(
target_branch,
target_tag,
target_sha,
target_workflow
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
target_workflow,
workflow_source_repository,
workflow_source_kind,
workflow_source_ref
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
",
)
@ -353,6 +372,9 @@ pub(crate) async fn insert_automation_ignoring_conflict(
.bind(target.tag.as_deref())
.bind(target.sha.as_deref())
.bind(&automation.workflow)
.bind(workflow_source.map(|source| source.repo.as_str()))
.bind(workflow_source.map(|source| source.kind.as_str()))
.bind(workflow_source.map(|source| source.reference.as_str()))
.execute(&mut **transaction)
.await?;
if result.rows_affected() == 0 {
@ -362,6 +384,34 @@ pub(crate) async fn insert_automation_ignoring_conflict(
Ok(true)
}
fn stored_workflow_source(
row: &SqliteRow,
id: &AutomationId,
) -> Result<Option<AutomationGitWorkflowSource>, AutomationStoreError> {
let repository = row.try_get::<Option<String>, _>("workflow_source_repository")?;
let kind = row.try_get::<Option<String>, _>("workflow_source_kind")?;
let reference = row.try_get::<Option<String>, _>("workflow_source_ref")?;
match (repository, kind, reference) {
(None, None, None) => Ok(None),
(Some(repo), Some(kind), Some(reference)) => {
let parsed_kind =
AutomationGitWorkflowSourceKind::from_str(&kind).map_err(|source| {
AutomationStoreError::StoredWorkflowSourceKind {
id: id.clone(),
kind,
source,
}
})?;
Ok(Some(AutomationGitWorkflowSource {
repo,
kind: parsed_kind,
reference,
}))
}
_ => Err(AutomationStoreError::StoredWorkflowSourceShape { id: id.clone() }),
}
}
fn stored_git_target(automation: &Automation) -> &GitRunTarget {
automation
.git_target()

View file

@ -6,11 +6,13 @@
use std::path::Path;
use fabro_automation::{
ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
AutomationStore, AutomationStoreError, AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
ApiTrigger, AutomationDraft, AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind,
AutomationId, AutomationReplace, AutomationRevision, AutomationStore, AutomationStoreError,
AutomationTrigger, AutomationTriggerId, ScheduleTrigger,
};
use fabro_db::Database;
use fabro_types::{GitRunTarget, RunTarget};
use sqlx::Row as _;
use tokio::fs;
async fn test_database() -> (tempfile::TempDir, Database) {
@ -40,15 +42,27 @@ fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
})
}
fn workflow_source(
kind: AutomationGitWorkflowSourceKind,
reference: &str,
) -> AutomationGitWorkflowSource {
AutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
kind,
reference: reference.to_string(),
}
}
fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
AutomationDraft {
id: AutomationId::new(id).unwrap(),
name: "Nightly".to_string(),
description: Some("Runs every night".to_string()),
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
id: AutomationId::new(id).unwrap(),
name: "Nightly".to_string(),
description: Some("Runs every night".to_string()),
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![
schedule("z-last", "0 2 * * *", false),
AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("custom-api-id").unwrap(),
@ -61,12 +75,13 @@ fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
fn replacement(name: &str, expression: &str) -> AutomationReplace {
AutomationReplace {
name: name.to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
name: name.to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![
schedule("nightly", expression, true),
AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("api").unwrap(),
@ -268,6 +283,110 @@ async fn insert_environment(pool: &fabro_db::DbPool, id: &str, provider: &str) {
.unwrap();
}
#[tokio::test]
async fn crud_round_trips_each_workflow_source_kind_and_clears_to_omission() {
let (_dir, database) = test_database().await;
let store = AutomationStore::new(database.clone_pool());
for (index, source) in [
workflow_source(AutomationGitWorkflowSourceKind::Branch, "main"),
workflow_source(AutomationGitWorkflowSourceKind::Tag, "release/v1"),
workflow_source(
AutomationGitWorkflowSourceKind::Commit,
"ABCDEF0123456789ABCDEF0123456789ABCDEF01",
),
]
.into_iter()
.enumerate()
{
let id = format!("source-{index}");
let mut value = draft(&id, true);
value.workflow_source = Some(source);
let created = store.create(value).await.unwrap();
let expected_reference = if index == 2 {
"abcdef0123456789abcdef0123456789abcdef01"
} else {
created.workflow_source.as_ref().unwrap().reference.as_str()
};
assert_eq!(
created.workflow_source.as_ref().unwrap().reference,
expected_reference
);
assert_eq!(store.get(&created.id).await.unwrap(), Some(created.clone()));
let mut cleared = replacement("Cleared", "30 4 * * *");
cleared.workflow_source = None;
let replaced = store
.replace(&created.id, &created.revision, cleared)
.await
.unwrap();
assert_eq!(replaced.workflow_source, None);
let columns = sqlx::query(
"SELECT workflow_source_repository, workflow_source_kind, workflow_source_ref \
FROM automations WHERE id = ?",
)
.bind(created.id.as_str())
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(
columns.get::<Option<String>, _>("workflow_source_repository"),
None
);
assert_eq!(
columns.get::<Option<String>, _>("workflow_source_kind"),
None
);
assert_eq!(
columns.get::<Option<String>, _>("workflow_source_ref"),
None
);
}
assert_eq!(store.list().await.unwrap().len(), 3);
}
#[tokio::test]
async fn corrupt_workflow_source_rows_are_rejected_as_stored_shape_errors() {
let (_dir, database) = test_database().await;
let store = AutomationStore::new(database.clone_pool());
let partial = store.create(draft("partial", true)).await.unwrap();
let unknown = store.create(draft("unknown", true)).await.unwrap();
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_update")
.execute(database.pool())
.await
.unwrap();
sqlx::query("PRAGMA ignore_check_constraints = ON")
.execute(database.pool())
.await
.unwrap();
sqlx::query(
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows' WHERE id = ?",
)
.bind(partial.id.as_str())
.execute(database.pool())
.await
.unwrap();
sqlx::query(
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows', \
workflow_source_kind = 'unknown', workflow_source_ref = 'main' WHERE id = ?",
)
.bind(unknown.id.as_str())
.execute(database.pool())
.await
.unwrap();
assert!(matches!(
store.get(&partial.id).await.unwrap_err(),
AutomationStoreError::StoredWorkflowSourceShape { .. }
));
assert!(matches!(
store.get(&unknown.id).await.unwrap_err(),
AutomationStoreError::StoredWorkflowSourceKind { .. }
));
}
#[tokio::test]
async fn disabled_api_trigger_normalizes_to_absent() {
let (_dir, database) = test_database().await;
@ -375,12 +494,13 @@ async fn failed_schedule_insert_rolls_back_parent_replace() {
.await
.unwrap();
let replacement = AutomationReplace {
name: "Should roll back".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule("blocked", "0 7 * * *", true)],
name: "Should roll back".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
workflow_source: None,
triggers: vec![schedule("blocked", "0 7 * * *", true)],
};
let err = store
@ -449,6 +569,7 @@ async fn legacy_import_is_transactional_and_sql_wins() {
assert_eq!(imported.name, "Imported");
assert_eq!(imported.revision, expected_revision);
assert_eq!(imported.workflow, "release");
assert_eq!(imported.workflow_source, None);
assert!(matches!(
imported.target,
RunTarget::Git(GitRunTarget {

View file

@ -688,6 +688,16 @@ fn main() {
("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]),
("AskFabro", "fabro_types::AskFabro", &[]),
("Automation", "fabro_automation::Automation", &[]),
(
"AutomationGitWorkflowSource",
"fabro_automation::AutomationGitWorkflowSource",
&[],
),
(
"AutomationGitWorkflowSourceKind",
"fabro_automation::AutomationGitWorkflowSourceKind",
&[],
),
("AutomationRef", "fabro_types::AutomationRef", &[]),
(
"AutomationTrigger",

View file

@ -15,8 +15,9 @@ mod generated {
}
pub mod types {
pub use fabro_automation::{
Automation, AutomationDraft as CreateAutomationRequest,
AutomationReplace as ReplaceAutomationRequest, AutomationTrigger,
Automation, AutomationDraft as CreateAutomationRequest, AutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind, AutomationReplace as ReplaceAutomationRequest,
AutomationTrigger,
};
pub use fabro_environment::Environment;
pub use fabro_model::{

View file

@ -1,9 +1,14 @@
use fabro_api::types::{
Automation as ApiAutomation, AutomationTrigger as ApiAutomationTrigger,
Automation as ApiAutomation, AutomationGitWorkflowSource as ApiAutomationGitWorkflowSource,
AutomationGitWorkflowSourceKind as ApiAutomationGitWorkflowSourceKind,
AutomationTrigger as ApiAutomationTrigger,
CreateAutomationRequest as ApiCreateAutomationRequest,
ReplaceAutomationRequest as ApiReplaceAutomationRequest,
};
use fabro_automation::{Automation, AutomationDraft, AutomationReplace, AutomationTrigger};
use fabro_automation::{
Automation, AutomationDraft, AutomationGitWorkflowSource, AutomationGitWorkflowSourceKind,
AutomationReplace, AutomationTrigger,
};
use serde_json::json;
// Compile-time witnesses that the generated API types resolve to the same
@ -12,6 +17,8 @@ use serde_json::json;
// checking and the build fails.
const _: fn(ApiAutomation) -> Automation = |value| value;
const _: fn(ApiAutomationTrigger) -> AutomationTrigger = |value| value;
const _: fn(ApiAutomationGitWorkflowSource) -> AutomationGitWorkflowSource = |value| value;
const _: fn(ApiAutomationGitWorkflowSourceKind) -> AutomationGitWorkflowSourceKind = |value| value;
const _: fn(ApiCreateAutomationRequest) -> AutomationDraft = |value| value;
const _: fn(ApiReplaceAutomationRequest) -> AutomationReplace = |value| value;
@ -103,3 +110,52 @@ fn replace_automation_request_round_trips_public_json_shape() {
let api: ApiReplaceAutomationRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn automation_workflow_sources_round_trip_each_public_json_shape() {
for (kind, reference) in [
("branch", "main"),
("tag", "release/v1"),
("commit", "abcdef0123456789abcdef0123456789abcdef01"),
] {
let value = json!({
"id": "nightly-deps",
"name": "Nightly dependency update",
"environment_id": "daytona-smoke",
"target": {
"kind": "git",
"repo": "fabro-sh/app",
"branch": "main"
},
"workflow": "dependency-update",
"workflow_source": {
"repo": "fabro-sh/workflows",
"kind": kind,
"ref": reference
},
"triggers": []
});
let api: ApiCreateAutomationRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
}
#[test]
fn automation_workflow_source_rejects_unknown_or_incomplete_coordinates() {
for source in [
json!({"repo": "fabro-sh/workflows", "kind": "unknown", "ref": "main"}),
json!({"repo": "fabro-sh/workflows", "kind": "branch"}),
json!({"repo": "fabro-sh/workflows", "kind": "branch", "ref": "main", "extra": true}),
] {
assert!(serde_json::from_value::<ApiAutomationGitWorkflowSource>(source).is_err());
}
let invalid_commit: ApiAutomationGitWorkflowSource = serde_json::from_value(json!({
"repo": "fabro-sh/workflows",
"kind": "commit",
"ref": "short"
}))
.unwrap();
assert!(invalid_commit.validate().is_err());
}

View file

@ -0,0 +1,41 @@
ALTER TABLE automations ADD COLUMN workflow_source_repository TEXT
CHECK (
workflow_source_repository IS NULL
OR length(workflow_source_repository) BETWEEN 3 AND 140
);
ALTER TABLE automations ADD COLUMN workflow_source_kind TEXT
CHECK (
workflow_source_kind IS NULL
OR workflow_source_kind IN ('branch', 'tag', 'commit')
);
ALTER TABLE automations ADD COLUMN workflow_source_ref TEXT
CHECK (
workflow_source_ref IS NULL
OR length(workflow_source_ref) BETWEEN 1 AND 255
);
CREATE TRIGGER automation_workflow_source_all_or_none_insert
BEFORE INSERT ON automations
WHEN
(NEW.workflow_source_repository IS NULL)
+ (NEW.workflow_source_kind IS NULL)
+ (NEW.workflow_source_ref IS NULL) NOT IN (0, 3)
BEGIN
SELECT RAISE(ABORT, 'automation workflow source must be entirely null or entirely present');
END;
CREATE TRIGGER automation_workflow_source_all_or_none_update
BEFORE UPDATE OF
workflow_source_repository,
workflow_source_kind,
workflow_source_ref
ON automations
WHEN
(NEW.workflow_source_repository IS NULL)
+ (NEW.workflow_source_kind IS NULL)
+ (NEW.workflow_source_ref IS NULL) NOT IN (0, 3)
BEGIN
SELECT RAISE(ABORT, 'automation workflow source must be entirely null or entirely present');
END;

View file

@ -28,6 +28,11 @@ pub const RUNS_MIGRATION_SQL: &str = include_str!("../migrations/2026071104_runs
/// the production schema without a filesystem path into this crate.
pub const RUN_EVENTS_MIGRATION_SQL: &str = include_str!("../migrations/2026082701_run_events.sql");
/// The automation workflow-source migration, exposed so storage fixtures can
/// install the production optional-coordinate columns and constraints.
pub const AUTOMATION_WORKFLOW_SOURCES_MIGRATION_SQL: &str =
include_str!("../migrations/2026082802_automation_workflow_sources.sql");
#[derive(Clone)]
pub struct Database {
pool: DbPool,

View file

@ -388,6 +388,32 @@ async fn automations_schema_enforces_aggregate_constraints() -> anyhow::Result<(
.await
.is_err()
);
for (repository, kind, reference) in [
(Some("fabro-sh/workflows"), None, None),
(None, Some("branch"), Some("main")),
(Some("fabro-sh/workflows"), Some("unknown"), Some("main")),
] {
let result = sqlx::query(
"UPDATE automations SET workflow_source_repository = ?, \
workflow_source_kind = ?, workflow_source_ref = ? WHERE id = 'valid'",
)
.bind(repository)
.bind(kind)
.bind(reference)
.execute(database.pool())
.await;
assert!(
result.is_err(),
"invalid workflow source row should be rejected"
);
}
sqlx::query(
"UPDATE automations SET workflow_source_repository = 'fabro-sh/workflows', \
workflow_source_kind = 'branch', workflow_source_ref = 'main' WHERE id = 'valid'",
)
.execute(database.pool())
.await?;
assert!(
sqlx::query(
"INSERT INTO automation_triggers (automation_id, id, enabled, expression) \
@ -431,6 +457,87 @@ async fn automations_schema_enforces_aggregate_constraints() -> anyhow::Result<(
Ok(())
}
#[tokio::test]
async fn automation_workflow_sources_migrate_without_rewriting_existing_rows() -> anyhow::Result<()>
{
let dir = tempfile::tempdir()?;
let db_path = dir.path().join("fabro.sqlite3");
let database = fabro_db::Database::connect(&db_path).await?;
database.migrate().await?;
rewind_automation_workflow_source_migration(&database).await?;
insert_minimal_automation(database.pool(), "preserved", 1).await?;
sqlx::query(
"INSERT INTO automation_triggers (automation_id, id, enabled, expression) \
VALUES ('preserved', 'nightly', 1, '0 3 * * *')",
)
.execute(database.pool())
.await?;
database.migrate().await?;
let row = sqlx::query(
"SELECT id, revision, target_repository, target_branch, target_tag, target_sha, \
target_workflow, workflow_source_repository, workflow_source_kind, workflow_source_ref \
FROM automations WHERE id = 'preserved'",
)
.fetch_one(database.pool())
.await?;
assert_eq!(row.get::<String, _>("id"), "preserved");
assert_eq!(row.get::<String, _>("revision"), "a".repeat(64));
assert_eq!(row.get::<String, _>("target_repository"), "fabro-sh/fabro");
assert_eq!(row.get::<String, _>("target_branch"), "main");
assert_eq!(row.get::<Option<String>, _>("target_tag"), None);
assert_eq!(row.get::<Option<String>, _>("target_sha"), None);
assert_eq!(row.get::<String, _>("target_workflow"), "release");
assert_eq!(
row.get::<Option<String>, _>("workflow_source_repository"),
None
);
assert_eq!(row.get::<Option<String>, _>("workflow_source_kind"), None);
assert_eq!(row.get::<Option<String>, _>("workflow_source_ref"), None);
let trigger_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM automation_triggers WHERE automation_id = 'preserved'",
)
.fetch_one(database.pool())
.await?;
assert_eq!(trigger_count, 1);
assert!(fabro_db::pre_migration_snapshot_path(&db_path).exists());
database.migrate().await?;
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM automations WHERE id = 'preserved'")
.fetch_one(database.pool())
.await?,
1
);
Ok(())
}
async fn rewind_automation_workflow_source_migration(
database: &fabro_db::Database,
) -> anyhow::Result<()> {
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_update")
.execute(database.pool())
.await?;
sqlx::query("DROP TRIGGER automation_workflow_source_all_or_none_insert")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_ref")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_kind")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN workflow_source_repository")
.execute(database.pool())
.await?;
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 2026082802")
.execute(database.pool())
.await?;
Ok(())
}
async fn insert_minimal_automation(
pool: &fabro_db::DbPool,
id: &str,

View file

@ -114,7 +114,8 @@ pub use pull_request::{
};
pub use reasoning::ReasoningOutput;
pub use repository::{
GitHubRepositorySlug, RepositoryProvider, RepositoryRef, normalize_git_commit_sha,
GitHubRepositorySlug, GitHubRepositorySlugError, RepositoryProvider, RepositoryRef,
is_valid_git_branch_name, is_valid_git_tag_name, normalize_git_commit_sha,
};
pub use run::{
DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance,

View file

@ -212,6 +212,33 @@ pub fn is_valid_github_ref_selector(value: &str) -> bool {
.all(|part| !part.is_empty() && !part.starts_with('.') && !has_lock_suffix(part))
}
/// Reports whether `value` is a canonical bare Git branch name.
///
/// Bare names exclude symbolic selectors, fully qualified refs, tag-prefixed
/// selectors, commit SHAs, and the `heads/` prefix accepted only after Git has
/// already entered the refs namespace.
#[must_use]
pub fn is_valid_git_branch_name(value: &str) -> bool {
is_valid_bare_git_ref_name(value) && !value.starts_with("heads/")
}
/// Reports whether `value` is a canonical bare Git tag name.
///
/// The input is a tag name rather than a selector, so `tags/`, `refs/`,
/// symbolic `HEAD`, and exact commit SHAs are rejected.
#[must_use]
pub fn is_valid_git_tag_name(value: &str) -> bool {
is_valid_bare_git_ref_name(value)
}
fn is_valid_bare_git_ref_name(value: &str) -> bool {
value != "HEAD"
&& !value.starts_with("tags/")
&& !value.starts_with("refs/")
&& normalize_git_commit_sha(value).is_none()
&& is_valid_github_ref_selector(value)
}
/// Validates and canonicalizes an exact Git commit SHA.
///
/// The grammar accepts exactly 40 untrimmed ASCII hexadecimal bytes. It does

View file

@ -66,20 +66,6 @@ pub struct GitRunTarget {
pub sha: Option<String>,
}
/// A bare branch or tag name: not `HEAD`, not a `refs/` or `tags/` selector,
/// not a commit SHA, and otherwise a valid GitHub ref selector.
///
/// The selector grammar is checked on the bare name so its leading-character
/// rules apply to the name itself, not to a prefixed selector that would mask
/// them.
fn is_bare_ref_name(name: &str) -> bool {
name != "HEAD"
&& !name.starts_with("tags/")
&& !name.starts_with("refs/")
&& repository::normalize_git_commit_sha(name).is_none()
&& repository::is_valid_github_ref_selector(name)
}
impl RunTarget {
/// The wire `kind` discriminator (`git`, `none`, or `folder`), for
/// diagnostics.
@ -102,10 +88,13 @@ impl RunTarget {
}) => {
let slug = GitHubRepositorySlug::try_new(&repo)
.ok_or(TargetValidationError::Repository)?;
if !is_bare_ref_name(&branch) || branch.starts_with("heads/") {
if !repository::is_valid_git_branch_name(&branch) {
return Err(TargetValidationError::Branch);
}
if tag.as_deref().is_some_and(|tag| !is_bare_ref_name(tag)) {
if tag
.as_deref()
.is_some_and(|tag| !repository::is_valid_git_tag_name(tag))
{
return Err(TargetValidationError::Tag);
}
let sha = sha

View file

@ -60,6 +60,8 @@ models/auth-session-user.ts
models/auth-session.ts
models/auth-sessions-response.ts
models/automation-api-trigger.ts
models/automation-git-workflow-source-kind.ts
models/automation-git-workflow-source.ts
models/automation-list-meta.ts
models/automation-list-response.ts
models/automation-ref.ts

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* How an automation interprets the workflow source `ref`.
*/
export const AutomationGitWorkflowSourceKind = {
BRANCH: 'branch',
TAG: 'tag',
COMMIT: 'commit'
} as const;
export type AutomationGitWorkflowSourceKind = typeof AutomationGitWorkflowSourceKind[keyof typeof AutomationGitWorkflowSourceKind];

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationGitWorkflowSourceKind } from './automation-git-workflow-source-kind';
/**
* Explicit GitHub coordinate from which an automation acquires workflow bytes. The kind makes `ref` unambiguous; this source is independent of the run target and does not provide a working branch for the run.
*/
export interface AutomationGitWorkflowSource {
/**
* GitHub repository slug in `owner/name` form.
*/
'repo': string;
'kind': AutomationGitWorkflowSourceKind;
/**
* Bare branch or tag name, or an exact 40-character commit SHA, as selected by `kind`. Prefixes such as `refs/heads/` and `refs/tags/` are not accepted.
*/
'ref': string;
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
@ -41,8 +44,9 @@ export interface Automation {
'last_error': string | null;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
*/
'workflow': string;
'workflow_source'?: AutomationGitWorkflowSource;
'triggers': Array<AutomationTrigger>;
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
@ -33,8 +36,9 @@ export interface CreateAutomationRequest {
'environment_id': string;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
*/
'workflow': string;
'workflow_source'?: AutomationGitWorkflowSource;
'triggers': Array<AutomationTrigger>;
}

View file

@ -31,6 +31,8 @@ export * from './auth-session-user';
export * from './auth-sessions-response';
export * from './automation';
export * from './automation-api-trigger';
export * from './automation-git-workflow-source';
export * from './automation-git-workflow-source-kind';
export * from './automation-list-meta';
export * from './automation-list-response';
export * from './automation-ref';

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationGitWorkflowSource } from './automation-git-workflow-source';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
@ -32,8 +35,9 @@ export interface ReplaceAutomationRequest {
'environment_id': string;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.
* Workflow slug or path resolved in the run-target checkout when `workflow_source` is omitted, or in the explicit workflow-source checkout when present.
*/
'workflow': string;
'workflow_source'?: AutomationGitWorkflowSource;
'triggers': Array<AutomationTrigger>;
}