diff --git a/apps/docs/docs/concepts/agents-as-code.md b/apps/docs/docs/concepts/agents-as-code.md
index 09213be2..20b54814 100644
--- a/apps/docs/docs/concepts/agents-as-code.md
+++ b/apps/docs/docs/concepts/agents-as-code.md
@@ -11,6 +11,12 @@ are explicit, and at least one trigger is required.
The Markdown body is the agent prompt. The frontmatter is execution configuration. Keeping both in
one reviewed file makes a model change, schedule change, or prompt change visible in Git history.
+Discovery uses the primary repository's optional project `developmentBranch`, with the connected
+GitHub default as fallback. The same source supplies skills and `.facility.yml`, new story bases,
+and configuration PR bases. See the [development source contract](../reference/project-manifest.md#development-source).
+Changing this setting does not replace queued/running turns' captured agent manifests or move
+existing story branches.
+
Supported triggers are:
- `manual`, used by trusted internal or direct API dispatch;
diff --git a/apps/docs/docs/reference/project-manifest.md b/apps/docs/docs/reference/project-manifest.md
index 090ae897..05c529ba 100644
--- a/apps/docs/docs/reference/project-manifest.md
+++ b/apps/docs/docs/reference/project-manifest.md
@@ -5,10 +5,38 @@ title: Project manifest
# `.facility.yml` reference
The primary repository's `.facility.yml` is the complete workspace environment contract. Facility
-loads it from the repository default branch when it starts or prepares a story. The schema is
+loads it from the project's selected development branch when it starts or prepares a story, falling
+back to the GitHub default branch when no override is set. The schema is
strict: unknown keys, duplicate YAML keys, aliases, invalid names, and wrong value types fail the
operation.
+## Development source
+
+In project settings, select an existing primary-repository branch, or leave the field empty to
+use the connected GitHub default. The API accepts `PATCH /v1/projects/:projectId` with
+`{ "developmentBranch": "develop" }`; use `null` to clear it. Updating this preference requires
+both `projects:write` and `repos:write`. Facility checks the branch using the project's active,
+organization-scoped GitHub App installation before saving. Missing or malformed branches are
+rejected, and unavailable or suspended installations cannot validate changes. Other project
+settings are preserved by this narrow update.
+
+The option is stored as `settings.developmentBranch`, outside this manifest: Facility must choose
+a ref before reading `.facility.yml`. It does not change GitHub's default branch or reinterpret
+legacy `default_branch` settings. Related repositories continue to use their own defaults.
+
+Agents, skills, the workspace manifest, new story branches, and agent-edit/kickstart configuration
+PRs all use the selected source. Proposal branches for an override are scoped to that source,
+so changing it does not rewrite a configuration PR opened against the previous default. GitHub's default and the selected development branch are protected
+from direct configuration writes and story-branch adoption. Existing story branches and worktrees
+are retained when the setting or branch tip changes; no reset or automatic rebase occurs. Queued
+and running turns retain their agent-manifest snapshots. Subsequent catalog refreshes and workspace
+preparations read the current selected source. A branch removed after selection fails source
+refresh/preparation rather than silently reading another branch.
+
+After saving, refresh the catalog and verify its source commit. A source change is not a deployment,
+merge, workspace wake, or approval to modify production. GitHub protections and human PR review
+remain the merge boundary.
+
## Complete example
```yaml
diff --git a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx
index 47394592..5f3c8050 100644
--- a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx
+++ b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx
@@ -1,6 +1,7 @@
import { Divider, Eyebrow, PillTag } from "@facility/ui";
import Link from "next/link";
import { ErrorNotice, Offline } from "@/components/offline";
+import { DevelopmentBranch } from "@/components/project/development-branch";
import { DisconnectRepository } from "@/components/project/disconnect-repository";
import { NativePreviews } from "@/components/project/native-previews";
import { WorkspaceVariables } from "@/components/story/workspace-variables";
@@ -41,6 +42,25 @@ export default async function ProjectSettingsPage({
+ {repos.ok && repos.data.find((repo) => repo.role === "primary") && (
+ repo.role === "primary")?.defaultBranch ?? "main"
+ }
+ canWrite={
+ can(me.ok ? me.data.permissions : [], "projects:write") &&
+ can(me.ok ? me.data.permissions : [], "repos:write")
+ }
+ />
+ )}
+
(
+ "PATCH",
+ `/v1/projects/${encodeURIComponent(projectId)}`,
+ {
+ developmentBranch,
+ idempotency_key: `ui-development-branch-${crypto.randomUUID()}`,
+ },
+ );
+ setPending(false);
+ if (!result.ok) {
+ setError(result.message);
+ return;
+ }
+ if (
+ !result.data?.settings ||
+ !("developmentBranch" in result.data.settings) ||
+ result.data.settings.developmentBranch !== developmentBranch
+ ) {
+ setError(
+ "The server did not confirm this setting. Refresh after the API deployment completes.",
+ );
+ return;
+ }
+ setValue(developmentBranch ?? "");
+ setSaved(developmentBranch ?? "");
+ setMessage(`Development source saved: ${developmentBranch ?? defaultBranch}.`);
+ router.refresh();
+ }
+ return (
+
+ development branch
+
+ Read agents, skills and .facility.yml from this branch of the primary repository. New
+ stories and configuration pull requests use the same base. Existing story branches and
+ queued or running turns keep their work.
+
+
+ Leave empty to use the GitHub default: {defaultBranch}.
+
+
+ Branch name
+
+ {
+ setValue(event.target.value);
+ setMessage("");
+ }}
+ />
+ {canWrite && (
+
+
+ {pending ? "saving…" : "save development branch"}
+
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/apps/web/test/development-branch.test.tsx b/apps/web/test/development-branch.test.tsx
new file mode 100644
index 00000000..44684af4
--- /dev/null
+++ b/apps/web/test/development-branch.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { DevelopmentBranch } from "../components/project/development-branch";
+
+const refresh = vi.hoisted(() => vi.fn());
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
+let container: HTMLDivElement, root: Root;
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ refresh.mockClear();
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+async function render(props = {}) {
+ await act(async () =>
+ root.render(
+ ,
+ ),
+ );
+}
+const input = () => container.querySelector("input") as HTMLInputElement;
+const button = () => container.querySelector("button") as HTMLButtonElement;
+async function change(value: string) {
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input(), value);
+ input().dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+it("shows fallback and sends only a scoped preference on save", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValue(Response.json({ settings: { developmentBranch: "develop" } }));
+ vi.stubGlobal("fetch", fetch);
+ await render();
+ expect(container.textContent).toContain("GitHub default: main");
+ expect(button().disabled).toBe(true);
+ await change("develop");
+ expect(fetch).not.toHaveBeenCalled();
+ await act(async () => button().click());
+ expect(JSON.parse(fetch.mock.calls[0]?.[1].body)).toEqual({
+ developmentBranch: "develop",
+ idempotency_key: expect.stringMatching(/^ui-development-branch-/),
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/v1/projects/proj_test",
+ expect.objectContaining({ method: "PATCH" }),
+ );
+ expect(container.querySelector("[role=status]")?.textContent).toContain("saved: develop");
+ expect(refresh).toHaveBeenCalledOnce();
+});
+it("clears the override and requires server confirmation", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json({ settings: {} }))
+ .mockResolvedValueOnce(Response.json({ settings: { developmentBranch: null } }));
+ vi.stubGlobal("fetch", fetch);
+ await render({ branch: "develop" });
+ await change("");
+ await act(async () => button().click());
+ expect(container.querySelector("[role=alert]")?.textContent).toContain("did not confirm");
+ expect(refresh).not.toHaveBeenCalled();
+ await act(async () => button().click());
+ expect(JSON.parse(fetch.mock.calls[1]?.[1].body).developmentBranch).toBeNull();
+ expect(container.querySelector("[role=status]")?.textContent).toContain("saved: main");
+});
+it("shows denied or unavailable updates without claiming success", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValue(
+ Response.json({ error: { message: "Branch unavailable" } }, { status: 503 }),
+ ),
+ );
+ await render();
+ await change("missing");
+ await act(async () => button().click());
+ expect(container.querySelector("[role=alert]")?.textContent).toBe("Branch unavailable");
+ expect(container.querySelector("[role=status]")).toBeNull();
+ expect(refresh).not.toHaveBeenCalled();
+});
+it("disables editing without permission", async () => {
+ await render({ canWrite: false, branch: "develop" });
+ expect(input().disabled).toBe(true);
+ expect(button()).toBeNull();
+});
+it("disables controls while branch validation is pending", async () => {
+ let complete: (response: Response) => void = () => undefined;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockReturnValue(
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ ),
+ );
+ await render();
+ await change("develop");
+ await act(async () => button().click());
+ expect(input().disabled).toBe(true);
+ expect(button().textContent).toBe("saving…");
+ await act(async () => complete(Response.json({ settings: { developmentBranch: "develop" } })));
+ expect(input().disabled).toBe(false);
+});
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 3e976a4b..798306b6 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -3966,6 +3966,10 @@
"properties": {
"nativePreviewsEnabled": {
"type": "boolean"
+ },
+ "developmentBranch": {
+ "nullable": true,
+ "type": "string"
}
},
"additionalProperties": {}
@@ -4390,12 +4394,20 @@
"properties": {
"nativePreviewsEnabled": {
"type": "boolean"
+ },
+ "developmentBranch": {
+ "nullable": true,
+ "type": "string"
}
},
"additionalProperties": {}
},
"nativePreviewsEnabled": {
"type": "boolean"
+ },
+ "developmentBranch": {
+ "nullable": true,
+ "type": "string"
}
}
}
diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts
index 8e0c75a5..fe740811 100644
--- a/packages/sdk/src/schema.d.ts
+++ b/packages/sdk/src/schema.d.ts
@@ -3707,6 +3707,7 @@ export interface operations {
description?: string;
settings?: {
nativePreviewsEnabled?: boolean;
+ developmentBranch?: string | null;
} & {
[key: string]: unknown;
};
@@ -4054,10 +4055,12 @@ export interface operations {
description?: string | null;
settings?: {
nativePreviewsEnabled?: boolean;
+ developmentBranch?: string | null;
} & {
[key: string]: unknown;
};
nativePreviewsEnabled?: boolean;
+ developmentBranch?: string | null;
};
};
};
diff --git a/services/api/package.json b/services/api/package.json
index 657d2030..18919ff9 100644
--- a/services/api/package.json
+++ b/services/api/package.json
@@ -12,7 +12,7 @@
"build:runtime": "tsup src/index.ts src/dev.ts src/start.ts src/worker.ts src/openapi.ts --format esm",
"typecheck": "tsc --noEmit",
"test": "vitest run --fileParallelism=false",
- "test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts",
+ "test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts test/development-branch-workspace.integration.test.ts",
"dev": "tsx watch src/dev.ts",
"dev:worker": "tsx watch src/worker.ts",
"start": "node dist/start.js",
diff --git a/services/api/src/agents/catalog.ts b/services/api/src/agents/catalog.ts
index f3280eb7..a02d035d 100644
--- a/services/api/src/agents/catalog.ts
+++ b/services/api/src/agents/catalog.ts
@@ -17,6 +17,7 @@ import {
} from "@facility/db";
import { and, asc, eq, notInArray, sql } from "drizzle-orm";
import { FacilityGithubClient, type GithubClientFactory } from "../github/client.js";
+import { configurationBranch, repositoryDevelopmentBranch } from "../github/development-branch.js";
import { readRepoFiles } from "../github/repo-files.js";
import { isAgentManifestPath, isProjectSkillPath, readAgentCatalogFiles } from "./catalog-files.js";
@@ -105,12 +106,23 @@ export class GithubAgentCatalogSource implements AgentCatalogSource {
}
try {
+ const developmentBranch = await repositoryDevelopmentBranch(this.db, repository);
const client = new FacilityGithubClient(await this.factory(installation.installationId), {
owner: repository.owner,
repo: repository.name,
defaultBranch: repository.defaultBranch,
+ developmentBranch,
+ });
+ const commitSha = await client.getBranchSha(developmentBranch).catch((error: unknown) => {
+ if (developmentBranch !== repository.defaultBranch) {
+ throw new AgentCatalogError(
+ "agent_catalog_branch_unavailable",
+ "The selected development branch could not be read from GitHub",
+ 503,
+ );
+ }
+ throw error;
});
- const commitSha = await client.getDefaultBranchSha();
// Authorization and the current ref are checked before every cache read. A changed
// repository, installation, or tenant can never reuse another scope's snapshot.
const key = JSON.stringify([
@@ -121,6 +133,7 @@ export class GithubAgentCatalogSource implements AgentCatalogSource {
repository.owner,
repository.name,
repository.defaultBranch,
+ developmentBranch,
commitSha,
]);
let snapshot = this.snapshots.get(key);
@@ -203,12 +216,14 @@ export class GithubAgentCatalogSource implements AgentCatalogSource {
const path = `.agents/${parsedName.data}.md`;
const agent = parseAgentManifest(input.source, path);
+ const developmentBranch = await repositoryDevelopmentBranch(this.db, repository);
const client = new FacilityGithubClient(await this.factory(installation.installationId), {
owner: repository.owner,
repo: repository.name,
defaultBranch: repository.defaultBranch,
+ developmentBranch,
});
- const baseCommitSha = await client.getDefaultBranchSha();
+ const baseCommitSha = await client.getBranchSha(developmentBranch);
if (baseCommitSha !== input.expectedCommitSha) {
throw new AgentCatalogError(
"agent_catalog_changed",
@@ -217,10 +232,12 @@ export class GithubAgentCatalogSource implements AgentCatalogSource {
}
const current = (await readRepoFiles(client, baseCommitSha, [path])).get(path);
if (!current) throw new AgentCatalogError("agent_not_found", "agent not found", 404);
- const branch = `facility/agent-${parsedName.data}`;
- const existing = (
- await client.listOpenPullRequestsForHead(branch, repository.defaultBranch)
- )[0];
+ const branch = configurationBranch(
+ `facility/agent-${parsedName.data}`,
+ developmentBranch,
+ repository.defaultBranch,
+ );
+ const existing = (await client.listOpenPullRequestsForHead(branch, developmentBranch))[0];
if (!existing && parseAgentManifest(current, path).hash === agent.hash) {
throw new AgentCatalogError("agent_manifest_unchanged", "The agent manifest is unchanged");
}
@@ -275,6 +292,7 @@ export class GithubAgentCatalogSource implements AgentCatalogSource {
"Facility does not merge this pull request automatically.",
].join("\n"),
head: branch,
+ base: developmentBranch,
}));
return {
agent,
diff --git a/services/api/src/github/client.ts b/services/api/src/github/client.ts
index 95114556..dd048fc7 100644
--- a/services/api/src/github/client.ts
+++ b/services/api/src/github/client.ts
@@ -111,6 +111,7 @@ export type RepoRef = {
owner: string;
repo: string;
defaultBranch: string;
+ developmentBranch?: string;
};
export type TreeItem = {
@@ -138,10 +139,14 @@ export class FacilityGithubClient {
}
async getDefaultBranchSha(): Promise {
+ return this.getBranchSha(this.repo.defaultBranch);
+ }
+
+ async getBranchSha(branch: string): Promise {
const response = await this.octokit.rest.repos.getBranch({
owner: this.repo.owner,
repo: this.repo.repo,
- branch: this.repo.defaultBranch,
+ branch,
});
return response.data.commit.sha;
}
@@ -273,5 +278,8 @@ export class FacilityGithubClient {
if (normalized === this.repo.defaultBranch) {
throw new Error(`Refusing to write to default branch ${this.repo.defaultBranch}`);
}
+ if (normalized === this.repo.developmentBranch) {
+ throw new Error(`Refusing to write to development branch ${this.repo.developmentBranch}`);
+ }
}
}
diff --git a/services/api/src/github/development-branch.ts b/services/api/src/github/development-branch.ts
new file mode 100644
index 00000000..ed139afa
--- /dev/null
+++ b/services/api/src/github/development-branch.ts
@@ -0,0 +1,54 @@
+import { createHash } from "node:crypto";
+import { type FacilityDb, projects } from "@facility/db";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+import { isSafeGitBranch } from "../workspaces/git-branch.js";
+
+export const DevelopmentBranchSchema = z
+ .string()
+ .refine(
+ (branch) =>
+ isSafeGitBranch(branch) &&
+ branch !== "@" &&
+ !branch.startsWith("refs/") &&
+ branch.split("/").every((part) => part && !part.startsWith(".") && !part.endsWith(".lock")),
+ "Use an existing branch name, without a refs/ prefix",
+ );
+
+/** Separate proposal heads prevent a source change from rewriting a PR against the old base. */
+export function configurationBranch(
+ prefix: string,
+ developmentBranch: string,
+ defaultBranch: string,
+) {
+ return developmentBranch === defaultBranch
+ ? prefix
+ : `${prefix}-${createHash("sha256").update(developmentBranch).digest("hex").slice(0, 12)}`;
+}
+
+export function projectDevelopmentBranch(settings: unknown, defaultBranch: string) {
+ const branch =
+ settings && typeof settings === "object" && !Array.isArray(settings)
+ ? (settings as Record).developmentBranch
+ : undefined;
+ return branch === undefined || branch === null
+ ? defaultBranch
+ : DevelopmentBranchSchema.parse(branch);
+}
+
+/** The override belongs to the primary repository; related repositories retain their defaults. */
+export async function repositoryDevelopmentBranch(
+ db: FacilityDb,
+ repository: { orgId: string; projectId: string; defaultBranch: string; role?: string },
+) {
+ if (repository.role === "related") return repository.defaultBranch;
+ const project = (
+ await db
+ .select({ settings: projects.settings })
+ .from(projects)
+ .where(and(eq(projects.orgId, repository.orgId), eq(projects.id, repository.projectId)))
+ .limit(1)
+ )[0];
+ if (!project) throw new Error("Project not found");
+ return projectDevelopmentBranch(project.settings, repository.defaultBranch);
+}
diff --git a/services/api/src/github/kickstart.ts b/services/api/src/github/kickstart.ts
index 50721f57..415b8455 100644
--- a/services/api/src/github/kickstart.ts
+++ b/services/api/src/github/kickstart.ts
@@ -3,6 +3,7 @@ import { type FacilityDb, githubInstallations } from "@facility/db";
import { eq } from "drizzle-orm";
import type { AppConfig, Principal } from "../types.js";
import { FacilityGithubClient, type GithubClientFactory, type TreeItem } from "./client.js";
+import { configurationBranch, repositoryDevelopmentBranch } from "./development-branch.js";
import { readRepoFiles } from "./repo-files.js";
export type KickstartAnswers = {
@@ -28,12 +29,14 @@ export type GithubRepositoryRow = {
owner: string;
name: string;
defaultBranch: string;
+ role?: string;
};
export async function createGithubClientForRepo(
db: FacilityDb,
factory: GithubClientFactory,
repository: GithubRepositoryRow,
+ developmentBranch?: string,
): Promise {
if (!repository.installationId) throw new Error("Repository has no GitHub installation");
const installation = (
@@ -50,6 +53,7 @@ export async function createGithubClientForRepo(
owner: repository.owner,
repo: repository.name,
defaultBranch: repository.defaultBranch,
+ developmentBranch: developmentBranch ?? (await repositoryDevelopmentBranch(db, repository)),
});
}
@@ -59,9 +63,11 @@ export async function kickstartPreview(
repository: GithubRepositoryRow,
answers: KickstartAnswers,
) {
- const client = await createGithubClientForRepo(db, factory, repository);
- const existing = await readRepoFiles(client, repository.defaultBranch);
- const detection = detectWorkspace(existing, answers.defaultBranch ?? repository.defaultBranch);
+ const developmentBranch = await repositoryDevelopmentBranch(db, repository);
+ const client = await createGithubClientForRepo(db, factory, repository, developmentBranch);
+ const commitSha = await client.getBranchSha(developmentBranch);
+ const existing = await readRepoFiles(client, commitSha);
+ const detection = detectWorkspace(existing, developmentBranch);
const workspaceAnswers = workspaceKickstartAnswers(
repository,
answers,
@@ -97,12 +103,16 @@ export async function kickstartRepo(args: {
repo: GithubRepositoryRow;
answers: KickstartAnswers;
}) {
- const client = await createGithubClientForRepo(args.db, args.factory, args.repo);
- const existing = await readRepoFiles(client, args.repo.defaultBranch);
- const detection = detectWorkspace(
- existing,
- args.answers.defaultBranch ?? args.repo.defaultBranch,
+ const developmentBranch = await repositoryDevelopmentBranch(args.db, args.repo);
+ const client = await createGithubClientForRepo(
+ args.db,
+ args.factory,
+ args.repo,
+ developmentBranch,
);
+ const baseSha = await client.getBranchSha(developmentBranch);
+ const existing = await readRepoFiles(client, baseSha);
+ const detection = detectWorkspace(existing, developmentBranch);
const rendered = renderWorkspaceKickstart(
workspaceKickstartAnswers(args.repo, args.answers, existing, detection.packageManager),
existing,
@@ -111,8 +121,11 @@ export async function kickstartRepo(args: {
throw new Error("Repository already contains the Facility 0.12 kickstart files");
}
- const branch = "facility/kickstart-0.12";
- const baseSha = await client.getDefaultBranchSha();
+ const branch = configurationBranch(
+ "facility/kickstart-0.12",
+ developmentBranch,
+ args.repo.defaultBranch,
+ );
const baseCommit = await client.getCommit(baseSha);
const treeSha = await client.createTree(
baseCommit.treeSha,
@@ -130,13 +143,14 @@ export async function kickstartRepo(args: {
await client.updateBranch(branch, commitSha);
}
const existingPullRequest = (
- await client.listOpenPullRequestsForHead(branch, args.repo.defaultBranch)
+ await client.listOpenPullRequestsForHead(branch, developmentBranch)
)[0];
const pr =
existingPullRequest ??
(await client.createPullRequest({
title: "feat!: configure Facility 0.12 story workspaces",
head: branch,
+ base: developmentBranch,
body: kickstartPrBody(rendered.files.map((file) => file.path)),
}));
return {
diff --git a/services/api/src/github/workspace-credentials.ts b/services/api/src/github/workspace-credentials.ts
index 90438b91..96219b9d 100644
--- a/services/api/src/github/workspace-credentials.ts
+++ b/services/api/src/github/workspace-credentials.ts
@@ -1,12 +1,14 @@
import { type FacilityDb, githubInstallations, projectRepositories } from "@facility/db";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { GithubMaintainerTokenFactory } from "./client.js";
+import { repositoryDevelopmentBranch } from "./development-branch.js";
import type { GithubGitIdentity } from "./git-identity.js";
export type WorkspaceRepository = {
owner: string;
name: string;
defaultBranch: string;
+ developmentBranch?: string;
role: "primary" | "related";
};
@@ -136,12 +138,15 @@ export class GithubWorkspaceCredentialBroker {
}
return {
gitIdentity: primaryCredential.gitIdentity,
- repositories: repositories.map((repository) => ({
- owner: repository.owner,
- name: repository.name,
- defaultBranch: repository.defaultBranch,
- role: repository.role as "primary" | "related",
- })),
+ repositories: await Promise.all(
+ repositories.map(async (repository) => ({
+ owner: repository.owner,
+ name: repository.name,
+ defaultBranch: repository.defaultBranch,
+ developmentBranch: await repositoryDevelopmentBranch(this.db, repository),
+ role: repository.role as "primary" | "related",
+ })),
+ ),
environment: {
GH_TOKEN: primaryToken,
GITHUB_TOKEN: primaryToken,
diff --git a/services/api/src/routes/v1/projects.ts b/services/api/src/routes/v1/projects.ts
index c3add1ee..9b263200 100644
--- a/services/api/src/routes/v1/projects.ts
+++ b/services/api/src/routes/v1/projects.ts
@@ -1,10 +1,12 @@
-import { newId } from "@facility/core";
+import { can, newId } from "@facility/core";
import { type FacilityDb, githubInstallations, projectRepositories, projects } from "@facility/db";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { ApiError, notFound } from "../../errors.js";
import { createGithubClientFactory } from "../../github/client.js";
+import { DevelopmentBranchSchema } from "../../github/development-branch.js";
+import { createGithubClientForRepo } from "../../github/kickstart.js";
import { removeRepositoryConnection } from "../../github/repository-connections.js";
import { projectNativePreviewsEnabled } from "../../workspaces/project-native-previews.js";
import {
@@ -33,7 +35,10 @@ const ProjectSchema = z.object({
});
const ProjectSettings = z
- .object({ nativePreviewsEnabled: z.boolean().optional() })
+ .object({
+ nativePreviewsEnabled: z.boolean().optional(),
+ developmentBranch: DevelopmentBranchSchema.nullable().optional(),
+ })
.catchall(z.unknown());
const RepositorySchema = z.object({
@@ -95,7 +100,9 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou
name: z.string().min(1).max(160),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
description: z.string().max(2_000).optional(),
- settings: ProjectSettings.optional(),
+ settings: ProjectSettings.refine((settings) => settings.developmentBranch == null, {
+ message: "Connect a primary repository before selecting a development branch",
+ }).optional(),
}),
response: { 200: ProjectSchema },
},
@@ -148,11 +155,14 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou
description: z.string().max(2_000).nullable().optional(),
settings: ProjectSettings.optional(),
nativePreviewsEnabled: z.boolean().optional(),
+ developmentBranch: DevelopmentBranchSchema.nullable().optional(),
})
.refine(
- (body) => body.settings === undefined || body.nativePreviewsEnabled === undefined,
+ (body) =>
+ body.settings === undefined ||
+ (body.nativePreviewsEnabled === undefined && body.developmentBranch === undefined),
{
- message: "Use settings or nativePreviewsEnabled, not both",
+ message: "Use settings or individual preferences, not both",
},
),
response: { 200: ProjectSchema },
@@ -166,28 +176,96 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou
description?: string | null;
settings?: Record;
nativePreviewsEnabled?: boolean;
+ developmentBranch?: string | null;
};
- const { nativePreviewsEnabled, ...fields } = body;
- const row = (
- await db
- .update(projects)
- .set(
- definedFields({
- ...fields,
- // The narrow UI update preserves unrelated settings, including concurrent writes.
- ...(nativePreviewsEnabled === undefined
- ? {}
- : {
- settings: sql`${projects.settings} || ${JSON.stringify({ nativePreviewsEnabled })}::jsonb`,
- }),
- updatedAt: new Date(),
- }),
- )
- .where(and(eq(projects.orgId, actor.orgId), eq(projects.id, id)))
- .returning(projectColumns)
- )[0];
- if (!row) throw notFound("Project not found");
- return presentProject(row);
+ return db.transaction(async (transaction) => {
+ const db = transaction as unknown as FacilityDb;
+ await db.execute(sql`select pg_advisory_xact_lock(hashtext(${`${actor.orgId}:${id}`}))`);
+ const current = await loadProject(db, actor.orgId, id);
+ const nextBranch =
+ body.settings === undefined
+ ? body.developmentBranch
+ : ((body.settings.developmentBranch as string | null | undefined) ?? null);
+ if (
+ nextBranch !== undefined &&
+ nextBranch !== ((current.settings as Record).developmentBranch ?? null)
+ ) {
+ if (!can(actor.permissions, "repos:write")) {
+ throw new ApiError(
+ 403,
+ "forbidden",
+ "Changing the development branch requires repos:write",
+ );
+ }
+ if (nextBranch !== null) {
+ const repository = (
+ await db
+ .select()
+ .from(projectRepositories)
+ .where(
+ and(
+ eq(projectRepositories.orgId, actor.orgId),
+ eq(projectRepositories.projectId, id),
+ eq(projectRepositories.role, "primary"),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!repository)
+ throw new ApiError(
+ 400,
+ "primary_repository_required",
+ "Connect a primary repository first",
+ );
+ try {
+ const client = await createGithubClientForRepo(
+ db,
+ app.githubClientFactory ?? createGithubClientFactory(context.config),
+ repository,
+ );
+ await client.getBranchSha(nextBranch);
+ } catch (error) {
+ if ((error as { status?: number }).status === 404) {
+ throw new ApiError(
+ 400,
+ "development_branch_not_found",
+ "The development branch is not available to the GitHub App",
+ );
+ }
+ throw new ApiError(
+ 503,
+ "development_branch_unavailable",
+ "Could not validate the branch with the GitHub installation",
+ );
+ }
+ }
+ }
+ const { nativePreviewsEnabled, developmentBranch, ...fields } = body;
+ const preferences = {
+ ...(nativePreviewsEnabled === undefined ? {} : { nativePreviewsEnabled }),
+ ...(developmentBranch === undefined ? {} : { developmentBranch }),
+ };
+ const row = (
+ await db
+ .update(projects)
+ .set(
+ definedFields({
+ ...fields,
+ // The narrow UI update preserves unrelated settings, including concurrent writes.
+ ...(Object.keys(preferences).length === 0
+ ? {}
+ : {
+ settings: sql`${projects.settings} || ${JSON.stringify(preferences)}::jsonb`,
+ }),
+ updatedAt: new Date(),
+ }),
+ )
+ .where(and(eq(projects.orgId, actor.orgId), eq(projects.id, id)))
+ .returning(projectColumns)
+ )[0];
+ if (!row) throw notFound("Project not found");
+ return presentProject(row);
+ });
},
);
diff --git a/services/api/src/stories/branch.ts b/services/api/src/stories/branch.ts
index 65974971..9132e63d 100644
--- a/services/api/src/stories/branch.ts
+++ b/services/api/src/stories/branch.ts
@@ -7,6 +7,7 @@ import {
workspaces,
} from "@facility/db";
import { and, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
+import { repositoryDevelopmentBranch } from "../github/development-branch.js";
import { isSafeGitBranch } from "../workspaces/git-branch.js";
import { appendStoryEvidence } from "./evidence.js";
@@ -17,6 +18,7 @@ export function observedStoryBranch(
story: Pick,
evidence: Pick,
defaultBranch: string | undefined,
+ developmentBranch?: string,
): string | null {
const branch = evidence.finalBranch;
if (
@@ -29,6 +31,7 @@ export function observedStoryBranch(
!branch ||
branch === story.branch ||
branch === defaultBranch ||
+ branch === developmentBranch ||
!isSafeGitBranch(branch)
)
return null;
@@ -88,6 +91,8 @@ export async function reconcileTurnBranch(
await db
.select({
id: projectRepositories.id,
+ orgId: projectRepositories.orgId,
+ projectId: projectRepositories.projectId,
role: projectRepositories.role,
defaultBranch: projectRepositories.defaultBranch,
})
@@ -104,7 +109,8 @@ export async function reconcileTurnBranch(
.limit(1)
)[0];
if (!repository) return;
- const branch = observedStoryBranch(story, observed, repository.defaultBranch);
+ const developmentBranch = await repositoryDevelopmentBranch(db, repository);
+ const branch = observedStoryBranch(story, observed, repository.defaultBranch, developmentBranch);
if (!branch) return;
await db.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${`facility:story-branch:${story.orgId}:${story.projectId}:${repository.id}:${branch}`}))`,
diff --git a/services/api/src/workspaces/project-environment.ts b/services/api/src/workspaces/project-environment.ts
index 741fca8e..180793d4 100644
--- a/services/api/src/workspaces/project-environment.ts
+++ b/services/api/src/workspaces/project-environment.ts
@@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm";
import { parseDocument } from "yaml";
import { z } from "zod";
import { FacilityGithubClient, type GithubClientFactory } from "../github/client.js";
+import { repositoryDevelopmentBranch } from "../github/development-branch.js";
import { decodeContent } from "../github/repo-files.js";
import type {
GithubWorkspaceCredentials,
@@ -185,7 +186,9 @@ export class GithubProjectManifestSource implements ProjectManifestSource {
repo: repository.name,
defaultBranch: repository.defaultBranch,
});
- const content = (await client.getContent(".facility.yml", repository.defaultBranch)) as {
+ const developmentBranch = await repositoryDevelopmentBranch(this.db, repository);
+ const commitSha = await client.getBranchSha(developmentBranch);
+ const content = (await client.getContent(".facility.yml", commitSha)) as {
type?: string;
content?: string;
encoding?: string;
@@ -557,6 +560,15 @@ export class ProjectEnvironmentService {
if (!isSafeGitBranch(input.branch)) {
throw new ProjectEnvironmentError("story_branch_invalid", "story branch is invalid");
}
+ if (
+ input.branch === repository.defaultBranch ||
+ input.branch === repository.developmentBranch
+ ) {
+ throw new ProjectEnvironmentError(
+ "story_branch_protected",
+ "Use a story branch, not a repository base branch",
+ );
+ }
const local = await this.runtime.exec(input.workspace, {
command: "git",
args: ["show-ref", "--verify", `refs/heads/${input.branch}`],
@@ -576,7 +588,12 @@ export class ProjectEnvironmentService {
? ["switch", input.branch]
: remote.exitCode === 0
? ["switch", "-c", input.branch, "--track", `origin/${input.branch}`]
- : ["switch", "-c", input.branch, `origin/${repository.defaultBranch}`],
+ : [
+ "switch",
+ "-c",
+ input.branch,
+ `origin/${repository.developmentBranch ?? repository.defaultBranch}`,
+ ],
cwd,
"git branch",
);
diff --git a/services/api/test/development-branch-workspace.integration.test.ts b/services/api/test/development-branch-workspace.integration.test.ts
new file mode 100644
index 00000000..ee0d6002
--- /dev/null
+++ b/services/api/test/development-branch-workspace.integration.test.ts
@@ -0,0 +1,121 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { newId } from "@facility/core";
+import { createDb, migrate, orgs, projects, stories, workspaces } from "@facility/db";
+import Docker from "dockerode";
+import { expect, it } from "vitest";
+import type { GithubWorkspaceCredentials } from "../src/github/workspace-credentials.js";
+import { DockerWorkspaceRuntime } from "../src/workspaces/docker.js";
+import { FakeWorkspaceRuntime } from "../src/workspaces/fake.js";
+import {
+ ProjectEnvironmentService,
+ parseProjectManifest,
+} from "../src/workspaces/project-environment.js";
+
+it("creates work from development and retains commits and dirty files after the base advances and compute is replaced", async () => {
+ const databaseUrl =
+ process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test";
+ const { db, client } = createDb(databaseUrl);
+ const root = await mkdtemp(join(tmpdir(), "facility-development-workspace-"));
+ const runtime =
+ process.env.FACILITY_E2E_DOCKER === "1"
+ ? new DockerWorkspaceRuntime(new Docker())
+ : new FakeWorkspaceRuntime(root);
+ const orgId = newId("org"),
+ projectId = newId("proj"),
+ storyId = newId("story"),
+ workspaceId = newId("ws");
+ const workspace = await runtime.create({
+ id: workspaceId,
+ image: process.env.FACILITY_WORKSPACE_TEST_IMAGE ?? "facility-runner:dev",
+ });
+ const command = async (script: string, cwd?: string) => {
+ const result = await runtime.exec(workspace, { command: "sh", args: ["-lc", script], cwd });
+ expect(result.exitCode, result.stderr).toBe(0);
+ return result.stdout.trim();
+ };
+ try {
+ await migrate(databaseUrl);
+ await db.insert(orgs).values({ id: orgId, name: orgId, slug: orgId });
+ await db.insert(projects).values({ id: projectId, orgId, name: projectId, slug: projectId });
+ await db.insert(stories).values({
+ id: storyId,
+ orgId,
+ projectId,
+ provider: "manual",
+ externalId: storyId,
+ title: "Development workspace",
+ branch: "feature/story",
+ createdBy: { type: "key", id: "test" },
+ });
+ await db.insert(workspaces).values({
+ id: workspaceId,
+ orgId,
+ projectId,
+ storyId,
+ provider: "fake",
+ externalRef: workspace.externalRef,
+ volumeRef: workspace.volumeRef,
+ state: "running",
+ });
+ const workspaceRoot = await command("pwd");
+ await command(
+ `mkdir -p branch-remotes/acme seed; git init --bare --initial-branch=main branch-remotes/acme/app.git; git init --initial-branch=main seed; cd seed; git config user.name Test; git config user.email test@example.com; git config commit.gpgsign false; git config core.hooksPath /dev/null; printf stable > README.md; git add README.md; git commit -m 'initial'; git remote add origin '${workspaceRoot}/branch-remotes/acme/app.git'; git push origin main; git switch -c develop; printf development > development.txt; git add development.txt; git commit -m 'development'; git push origin develop`,
+ );
+ const developmentSha = await command("git rev-parse develop", "seed");
+ const manifest = parseProjectManifest(
+ "version: 1\nrepositories:\n primary: github.com/acme/app\nenvironment:\n setup: 'true'\n start: 'true'\n",
+ );
+ const credentials: GithubWorkspaceCredentials = {
+ repositories: [
+ {
+ owner: "acme",
+ name: "app",
+ defaultBranch: "main",
+ developmentBranch: "develop",
+ role: "primary",
+ },
+ ],
+ environment: {},
+ expiresAt: new Date(Date.now() + 60_000),
+ gitIdentity: { name: "Test", email: "test@example.com" },
+ };
+ const service = new ProjectEnvironmentService(
+ db,
+ runtime,
+ `file://${workspaceRoot}/branch-remotes`,
+ );
+ const input = { orgId, projectId, workspace, manifest, credentials, branch: "feature/story" };
+ for (const branch of ["main", "develop"])
+ await expect(service.prepare({ ...input, branch })).rejects.toMatchObject({
+ code: "story_branch_protected",
+ });
+ await service.prepare(input);
+ expect(await command("git rev-parse HEAD", "repos/acme/app")).toBe(developmentSha);
+ expect(await command("cat development.txt", "repos/acme/app")).toBe("development");
+ await command(
+ "git config commit.gpgsign false; git config core.hooksPath /dev/null; printf story > story.txt; git add story.txt; git commit -m 'story'; printf dirty > dirty.txt",
+ "repos/acme/app",
+ );
+ const head = await command("git rev-parse HEAD", "repos/acme/app");
+ await command(
+ "printf advanced > advanced.txt; git add advanced.txt; git commit -m 'advance'; git push origin develop",
+ "seed",
+ );
+ await runtime.replaceCompute(workspace);
+ const resumed = await runtime.wake(workspace);
+ Object.assign(workspace, resumed);
+ await service.prepare(input);
+ expect(await command("git rev-parse HEAD", "repos/acme/app")).toBe(head);
+ expect(await command("git branch --show-current", "repos/acme/app")).toBe("feature/story");
+ expect(await command("cat dirty.txt", "repos/acme/app")).toBe("dirty");
+ expect(await command("test ! -f advanced.txt && echo preserved", "repos/acme/app")).toBe(
+ "preserved",
+ );
+ } finally {
+ await runtime.destroy(workspace);
+ await client.end();
+ await rm(root, { recursive: true, force: true });
+ }
+}, 120_000);
diff --git a/services/api/test/development-branch.integration.test.ts b/services/api/test/development-branch.integration.test.ts
new file mode 100644
index 00000000..7e6ac967
--- /dev/null
+++ b/services/api/test/development-branch.integration.test.ts
@@ -0,0 +1,376 @@
+import { randomUUID } from "node:crypto";
+import { generateApiKey, newId } from "@facility/core";
+import {
+ apiKeys,
+ createDb,
+ githubInstallations,
+ migrate,
+ orgs,
+ projectRepositories,
+ projects,
+ roles,
+ stories,
+ storyConversations,
+ turns,
+} from "@facility/db";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, expect, it, vi } from "vitest";
+import { AgentCatalogService, GithubAgentCatalogSource } from "../src/agents/catalog.js";
+import { buildApp } from "../src/app.js";
+import type { GithubClientFactory } from "../src/github/client.js";
+import { repositoryDevelopmentBranch } from "../src/github/development-branch.js";
+import { kickstartRepo } from "../src/github/kickstart.js";
+import { GithubWorkspaceCredentialBroker } from "../src/github/workspace-credentials.js";
+import type { AppConfig, Principal } from "../src/types.js";
+import { GithubProjectManifestSource } from "../src/workspaces/project-environment.js";
+
+const databaseUrl =
+ process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test";
+const { db, client } = createDb(databaseUrl);
+const orgId = newId("org"),
+ foreignOrgId = newId("org"),
+ projectId = newId("proj"),
+ otherProjectId = newId("proj"),
+ foreignProjectId = newId("proj");
+const installationId = newId("ghi"),
+ repositoryId = newId("repo"),
+ storyId = newId("story"),
+ conversationId = newId("msg"),
+ turnId = newId("turn");
+const config: AppConfig = {
+ databaseUrl,
+ secretMasterKey: Buffer.alloc(32, 14).toString("base64"),
+ port: 4400,
+ publicUrl: "https://api.facility.test",
+ facilityInsecureDev: true,
+ workspaceDriver: "docker",
+ workspaceImage: "test",
+ logLevel: "silent",
+};
+let app: Awaited>;
+let writer: string, reader: string, projectWriter: string;
+const mainSha = "a".repeat(40),
+ developSha = "b".repeat(40);
+const source = (label: string) =>
+ `---\nname: builder\ndescription: ${label}\nengine: codex\nmodel: gpt-5.5\nenabled: true\ntriggers:\n - type: manual\n---\nBuild ${label}.\n`;
+const manifest = (label: string) =>
+ `version: 1\nrepositories:\n primary: github.com/acme/app\nenvironment:\n setup: echo ${label}\n start: 'true'\n`;
+const getBranch = vi.fn(async ({ branch }: Record) => {
+ if (branch === "develop" && developmentUnavailable)
+ throw Object.assign(new Error("missing"), { status: 404 });
+ if (branch === "missing") throw Object.assign(new Error("missing"), { status: 404 });
+ if (branch === "denied") throw Object.assign(new Error("denied"), { status: 403 });
+ return { data: { commit: { sha: branch === "main" ? mainSha : developSha } } };
+});
+let kickstart = false;
+let developmentUnavailable = false;
+const getContent = vi.fn(async ({ path, ref }: Record) => {
+ const label = ref === mainSha ? "stable" : "development";
+ let content: unknown;
+ if (kickstart) {
+ if (path === "package.json") content = { type: "file", content: '{"scripts":{"dev":"vite"}}' };
+ else throw Object.assign(new Error("missing"), { status: 404 });
+ } else if (path === ".agents") content = [{ type: "file", path: ".agents/builder.md" }];
+ else if (path === ".agents/builder.md") content = { type: "file", content: source(label) };
+ else if (path === ".facility.yml") content = { type: "file", content: manifest(label) };
+ else if (path === ".claude/skills") content = [{ type: "dir", path: ".claude/skills/planning" }];
+ else if (path === ".claude/skills/planning")
+ content = [{ type: "file", path: ".claude/skills/planning/SKILL.md" }];
+ else if (path === ".claude/skills/planning/SKILL.md")
+ content = { type: "file", content: `---\nname: planning\ndescription: ${label}\n---\nPlan.` };
+ else throw Object.assign(new Error("missing"), { status: 404 });
+ return { data: content };
+});
+const createPullRequest = vi.fn(async () => ({
+ data: { number: 42, html_url: "https://github.com/acme/app/pull/42" },
+}));
+const createRef = vi.fn(async () => ({ data: {} }));
+const createCommit = vi.fn(async () => ({ data: { sha: "c".repeat(40) } }));
+const factory = (async () => ({
+ rest: {
+ repos: { getBranch, getContent },
+ git: {
+ getCommit: async () => ({ data: { sha: developSha, tree: { sha: "d".repeat(40) } } }),
+ createBlob: async () => ({ data: { sha: "e".repeat(40) } }),
+ createTree: async () => ({ data: { sha: "f".repeat(40) } }),
+ createCommit,
+ createRef,
+ updateRef: async () => ({ data: {} }),
+ },
+ pulls: { list: async () => ({ data: [] }), create: createPullRequest },
+ },
+})) as unknown as GithubClientFactory;
+beforeAll(async () => {
+ await migrate(databaseUrl);
+ await db.insert(orgs).values([orgId, foreignOrgId].map((id) => ({ id, name: id, slug: id })));
+ await db.insert(projects).values([
+ { id: projectId, orgId, name: "Development", slug: projectId, settings: { retained: true } },
+ { id: otherProjectId, orgId, name: "Other", slug: otherProjectId },
+ { id: foreignProjectId, orgId: foreignOrgId, name: "Foreign", slug: foreignProjectId },
+ ]);
+ await db.insert(githubInstallations).values({
+ id: installationId,
+ orgId,
+ installationId: Math.floor(Math.random() * 1e9),
+ accountLogin: "acme",
+ accountId: 1234,
+ targetType: "Organization",
+ });
+ await db.insert(projectRepositories).values({
+ id: repositoryId,
+ orgId,
+ projectId,
+ installationId,
+ owner: "acme",
+ name: "app",
+ defaultBranch: "main",
+ role: "primary",
+ });
+ for (const [permissions, assign] of [
+ [
+ ["projects:read", "projects:write", "repos:write"],
+ (token: string) => {
+ writer = token;
+ },
+ ],
+ [
+ ["projects:read"],
+ (token: string) => {
+ reader = token;
+ },
+ ],
+ [
+ ["projects:write"],
+ (token: string) => {
+ projectWriter = token;
+ },
+ ],
+ ] as const) {
+ const roleId = newId("role"),
+ generated = await generateApiKey("fak");
+ await db
+ .insert(roles)
+ .values({ id: roleId, orgId, name: roleId, permissions: [...permissions] });
+ await db.insert(apiKeys).values({
+ id: generated.id,
+ orgId,
+ roleId,
+ projectId,
+ scopeType: "project",
+ name: "branch-test",
+ prefix: generated.lookup,
+ last4: generated.last4,
+ hash: generated.hash,
+ });
+ assign(generated.secret);
+ }
+ await db.insert(stories).values({
+ id: storyId,
+ orgId,
+ projectId,
+ title: "Retained story",
+ provider: "manual",
+ externalId: storyId,
+ branch: "feature/retained",
+ createdBy: { type: "key", id: "test" },
+ });
+ await db.insert(storyConversations).values({ id: conversationId, orgId, projectId, storyId });
+ await db.insert(turns).values({
+ id: turnId,
+ orgId,
+ projectId,
+ storyId,
+ conversationId,
+ agentName: "builder",
+ manifestHash: "old-hash",
+ manifest: { prompt: "immutable" },
+ engine: "codex",
+ model: "gpt-5.5",
+ state: "queued",
+ triggerType: "manual",
+ createdBy: { type: "key", id: "test" },
+ });
+ app = await buildApp(config);
+ app.githubClientFactory = factory;
+ await app.ready();
+});
+afterAll(async () => {
+ await app?.close();
+ await client.end();
+});
+const patch = (
+ payload: Record,
+ target = projectId,
+ token = writer,
+ key = randomUUID(),
+) =>
+ app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${target}`,
+ headers: { authorization: `Bearer ${token}`, "idempotency-key": key },
+ payload,
+ });
+it("selects development sources and preserves metadata, settings, stories and queued/running snapshots", async () => {
+ const catalog = new GithubAgentCatalogSource(db, factory);
+ expect((await catalog.load(orgId, projectId)).sources[0]?.source).toContain("stable");
+ const key = randomUUID();
+ const response = await patch({ developmentBranch: "develop" }, projectId, writer, key);
+ expect(response.statusCode).toBe(200);
+ expect(response.json().settings).toEqual({ retained: true, developmentBranch: "develop" });
+ expect((await patch({ developmentBranch: "develop" }, projectId, writer, key)).json()).toEqual(
+ response.json(),
+ );
+ const snapshot = await catalog.load(orgId, projectId);
+ expect(snapshot.commitSha).toBe(developSha);
+ expect(snapshot.sources[0]?.source).toContain("development");
+ expect(snapshot.skills?.[0]?.source).toContain("development");
+ expect(
+ (await new GithubProjectManifestSource(db, factory).load(orgId, projectId)).environment.setup,
+ ).toBe("echo development");
+ const tokenFactory = vi.fn(async () => ({
+ token: "fake-installation-token",
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ gitIdentity: { name: "test[bot]", email: "test@example.com" },
+ }));
+ expect(
+ (await new GithubWorkspaceCredentialBroker(db, tokenFactory).issue(orgId, projectId))
+ .repositories[0],
+ ).toMatchObject({ defaultBranch: "main", developmentBranch: "develop" });
+ expect(
+ await repositoryDevelopmentBranch(db, {
+ orgId,
+ projectId,
+ role: "related",
+ defaultBranch: "stable",
+ }),
+ ).toBe("stable");
+ expect(
+ (await db.select().from(projectRepositories).where(eq(projectRepositories.id, repositoryId)))[0]
+ ?.defaultBranch,
+ ).toBe("main");
+ expect((await db.select().from(stories).where(eq(stories.id, storyId)))[0]?.branch).toBe(
+ "feature/retained",
+ );
+ expect((await db.select().from(turns).where(eq(turns.id, turnId)))[0]).toMatchObject({
+ manifestHash: "old-hash",
+ manifest: { prompt: "immutable" },
+ state: "queued",
+ });
+ await db.update(turns).set({ state: "running" }).where(eq(turns.id, turnId));
+ expect((await patch({ developmentBranch: "main" })).statusCode).toBe(200);
+ expect((await db.select().from(turns).where(eq(turns.id, turnId)))[0]).toMatchObject({
+ manifestHash: "old-hash",
+ manifest: { prompt: "immutable" },
+ state: "running",
+ });
+ expect((await patch({ developmentBranch: "develop" })).statusCode).toBe(200);
+});
+it("targets agent and kickstart configuration PRs at the selected development base", async () => {
+ await patch({ developmentBranch: "develop" });
+ const catalog = new GithubAgentCatalogSource(db, factory);
+ await catalog.proposeUpdate(orgId, projectId, {
+ name: "builder",
+ source: source("edited"),
+ expectedCommitSha: developSha,
+ });
+ expect(createPullRequest).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ base: "develop",
+ head: expect.stringMatching(/^facility\/agent-builder-[a-f0-9]{12}$/),
+ }),
+ );
+ expect(createCommit).toHaveBeenLastCalledWith(expect.objectContaining({ parents: [developSha] }));
+ const repo = (
+ await db.select().from(projectRepositories).where(eq(projectRepositories.id, repositoryId))
+ )[0];
+ if (!repo) throw Error("Missing fixture");
+ kickstart = true;
+ try {
+ await kickstartRepo({
+ db,
+ factory,
+ config,
+ principal: { orgId } as Principal,
+ projectId,
+ repo,
+ answers: {},
+ });
+ expect(createPullRequest).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ base: "develop",
+ head: expect.stringMatching(/^facility\/kickstart-0\.12-[a-f0-9]{12}$/),
+ }),
+ );
+ expect(createCommit).toHaveBeenLastCalledWith(
+ expect.objectContaining({ parents: [developSha] }),
+ );
+ } finally {
+ kickstart = false;
+ }
+});
+it("rejects unauthorized, cross-project, cross-tenant, malformed and unavailable branch changes", async () => {
+ for (const [target, token] of [
+ [projectId, reader],
+ [projectId, projectWriter],
+ [otherProjectId, writer],
+ [foreignProjectId, writer],
+ ]) {
+ getBranch.mockClear();
+ expect((await patch({ developmentBranch: "other" }, target, token)).statusCode).toBe(
+ target === projectId ? 403 : 404,
+ );
+ expect(getBranch).not.toHaveBeenCalled();
+ }
+ for (const branch of ["refs/heads/dev", "", "dev~1", "a//b", 1, true])
+ expect((await patch({ developmentBranch: branch })).statusCode).toBe(400);
+ expect((await patch({ developmentBranch: "develop", settings: {} })).statusCode).toBe(400);
+ expect((await patch({ developmentBranch: "missing" })).statusCode).toBe(400);
+ expect((await patch({ developmentBranch: "denied" })).statusCode).toBe(503);
+ expect((await patch({ settings: {} }, projectId, projectWriter)).statusCode).toBe(403);
+ const previous = (await db.select().from(projects).where(eq(projects.id, projectId)))[0]
+ ?.settings;
+ expect(previous).toMatchObject({ developmentBranch: "develop" });
+ await db
+ .update(githubInstallations)
+ .set({ suspendedAt: new Date() })
+ .where(eq(githubInstallations.id, installationId));
+ try {
+ expect((await patch({ developmentBranch: "other" })).statusCode).toBe(503);
+ await expect(
+ new GithubAgentCatalogSource(db, factory).load(orgId, projectId),
+ ).rejects.toMatchObject({ code: "github_installation_unavailable" });
+ } finally {
+ await db
+ .update(githubInstallations)
+ .set({ suspendedAt: null })
+ .where(eq(githubInstallations.id, installationId));
+ }
+});
+it("does not dispatch a cached catalog from another source when the selected branch is removed", async () => {
+ await patch({ developmentBranch: "develop" });
+ const catalog = new AgentCatalogService(db, new GithubAgentCatalogSource(db, factory));
+ await catalog.sync(orgId, projectId);
+ developmentUnavailable = true;
+ try {
+ await expect(catalog.get(orgId, projectId, "builder")).rejects.toMatchObject({
+ code: "agent_catalog_branch_unavailable",
+ });
+ await expect(
+ new GithubProjectManifestSource(db, factory).load(orgId, projectId),
+ ).rejects.toMatchObject({ status: 404 });
+ } finally {
+ developmentUnavailable = false;
+ }
+});
+it("clears the override atomically and restores default-branch discovery", async () => {
+ expect((await patch({ developmentBranch: null })).json().settings).toEqual({
+ retained: true,
+ developmentBranch: null,
+ });
+ expect((await new GithubAgentCatalogSource(db, factory).load(orgId, projectId)).commitSha).toBe(
+ mainSha,
+ );
+ await expect(
+ new GithubAgentCatalogSource(db, factory).load(foreignOrgId, projectId),
+ ).rejects.toMatchObject({ code: "primary_repository_not_found" });
+});
diff --git a/services/api/test/development-branch.test.ts b/services/api/test/development-branch.test.ts
new file mode 100644
index 00000000..137635cd
--- /dev/null
+++ b/services/api/test/development-branch.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import {
+ configurationBranch,
+ DevelopmentBranchSchema,
+ projectDevelopmentBranch,
+} from "../src/github/development-branch.js";
+
+describe("project development branch", () => {
+ it("retains legacy proposal heads and separates development sources, including names that slugify alike", () => {
+ const prefix = "facility/kickstart-0.12";
+ expect(configurationBranch(prefix, "main", "main")).toBe(prefix);
+ const heads = ["develop", "team/dev", "team-dev"].map((branch) =>
+ configurationBranch(prefix, branch, "main"),
+ );
+ expect(new Set(heads).size).toBe(3);
+ expect(heads.every((head) => DevelopmentBranchSchema.safeParse(head).success)).toBe(true);
+ });
+ it("falls back only when unset, including legacy settings", () => {
+ for (const settings of [{}, { developmentBranch: null }, { default_branch: "develop" }]) {
+ expect(projectDevelopmentBranch(settings, "main")).toBe("main");
+ }
+ expect(projectDevelopmentBranch({ developmentBranch: "develop" }, "main")).toBe("develop");
+ });
+ it.each([
+ "",
+ "-develop",
+ "refs/heads/develop",
+ "develop~1",
+ "a..b",
+ "a//b",
+ ".hidden",
+ "a/.hidden",
+ "a.lock",
+ "a/b.lock",
+ "@",
+ "dev\nmain",
+ "a\\b",
+ "a/",
+ "a.",
+ ])("rejects %j", (branch) => {
+ expect(DevelopmentBranchSchema.safeParse(branch).success).toBe(false);
+ expect(() => projectDevelopmentBranch({ developmentBranch: branch }, "main")).toThrow();
+ });
+ it.each([
+ "main",
+ "dev",
+ "develop",
+ "release/next",
+ "team-1/integration",
+ ])("accepts %s", (branch) => {
+ expect(DevelopmentBranchSchema.parse(branch)).toBe(branch);
+ });
+});
diff --git a/services/api/test/github-client.test.ts b/services/api/test/github-client.test.ts
index 89ed858f..6e6a8ab7 100644
--- a/services/api/test/github-client.test.ts
+++ b/services/api/test/github-client.test.ts
@@ -39,6 +39,7 @@ function fixture() {
owner: "acme",
repo: "app",
defaultBranch: "main",
+ developmentBranch: "develop",
}),
createRef,
updateRef,
@@ -95,4 +96,19 @@ describe("FacilityGithubClient", () => {
expect(updateRef).not.toHaveBeenCalled();
expect(createPullRequest).not.toHaveBeenCalled();
});
+ it("also refuses writes and PR heads on the development branch", async () => {
+ const { client, createRef, updateRef, createPullRequest } = fixture();
+ await expect(client.createBranch("develop", "a".repeat(40))).rejects.toThrow(
+ /development branch/,
+ );
+ await expect(client.updateBranch("refs/heads/develop", "a".repeat(40))).rejects.toThrow(
+ /development branch/,
+ );
+ await expect(
+ client.createPullRequest({ title: "Unsafe", body: "", head: "heads/develop" }),
+ ).rejects.toThrow(/development branch/);
+ expect(createRef).not.toHaveBeenCalled();
+ expect(updateRef).not.toHaveBeenCalled();
+ expect(createPullRequest).not.toHaveBeenCalled();
+ });
});
diff --git a/services/api/test/story-branch.test.ts b/services/api/test/story-branch.test.ts
index 665e0c2f..85f1ff19 100644
--- a/services/api/test/story-branch.test.ts
+++ b/services/api/test/story-branch.test.ts
@@ -12,6 +12,14 @@ describe("verified story branch changes", () => {
it("follows the branch recorded after a completed workspace turn", () => {
expect(observedStoryBranch(story, evidence, "main")).toBe("chore/story");
});
+ it("protects the development branch as well as the GitHub default", () => {
+ for (const finalBranch of ["main", "develop"]) {
+ expect(
+ observedStoryBranch(story, { ...evidence, finalBranch }, "main", "develop"),
+ ).toBeNull();
+ }
+ expect(observedStoryBranch(story, evidence, "main", "develop")).toBe("chore/story");
+ });
it.each([
["an existing PR", { ...story, pullRequestNumber: 42 }],
["a concurrent branch change", { ...story, branch: "fix/other" }],