ci: upload the repo instead of bind-mounting it from the host
createContainer bound repoPath(name) into the CI container. The Docker
daemon resolves bind sources on the host. That path only exists when
HearthForge runs directly on the host. Inside a container the daemon
found nothing there and mounted an empty directory instead. Every clone
failed, and the first visible error appeared three steps later.
The bare repo is now streamed in with PUT /containers/{id}/archive. No
host path is named anywhere, so both deployments behave the same. CI
steps also no longer hold a handle on the real repo.
- ci: check the exit code of the clone and of the `clear` reset. Both
were discarded, which is why the mount failure stayed invisible
- ci: validateCiConfig rejects a cache path inside clone_project_to. The
cache volume is mounted before the clone runs. That leaves the
directory non-empty, and git refuses to clone into it
- ci: record setup-phase errors as a synthetic "pipeline setup" step.
The old branch only fired when no step rows existed, but rows are
inserted before the container starts, so the reason never reached the
UI
- ci: flatten tar ownership to root and pass -c safe.directory on the
clone. Host uids trip git's dubious-ownership check, and exec runs as
whatever user the image defaults to
- tests: mock the archive upload endpoint. Cover the upload, a failing
clone, and a cache path inside the cloneMsrc/services/ci.ts
@@ -59,6 +59,8 @@ export interface TriggerOpts {
// Reserved TOML table names that are not steps
const RESERVED_TABLES = new Set(["on", "variables"]);
const CONTAINER_REPO_PATH = "/hearthforge-repo.git";
// In-memory map of running tasks for cancellation
const runningTasks = new Map<
number,
@@ -183,6 +185,31 @@ export function parseCiConfig(tomlStr: string): CiConfig | null {
};
}
/**
* Run-start checks kept out of parseCiConfig, which can only report one
* generic failure. Returns an error string, or null when the config is usable.
*/
export function validateCiConfig(cfg: CiConfig): string | null {
if (!cfg.clone_project_to || !cfg.cache) return null;
const clone = path.resolve(cfg.clone_project_to);
for (const entry of cfg.cache) {
const cachePath = path.resolve(entry);
if (
cachePath === clone ||
cachePath.startsWith(`${clone}${path.sep}`)
) {
return (
`cache path "${entry}" is inside clone_project_to ("${cfg.clone_project_to}"). ` +
"The cache volume is mounted before the clone runs, which leaves the " +
"directory non-empty, and git refuses to clone into it. Move the cache " +
"outside the clone directory."
);
}
}
return null;
}
// --- Trigger matching ---
export function shouldTriggerPush(cfg: CiConfig, branch: string): boolean {
@@ -322,10 +349,11 @@ async function createContainer(
runId: number,
repoName: string,
cfg: CiConfig,
repoAbsPath: string,
envVars: string[],
): Promise<string> {
const binds: string[] = [`${repoAbsPath}:/hearthforge-repo.git:ro`];
// No bind for the repo: the Docker daemon resolves bind sources on the
// host, where HearthForge's own paths do not exist. uploadRepo copies it in.
const binds: string[] = [];
if (cfg.cache) {
for (const cachePath of cfg.cache) {
const volName = `hearthforge-ci-cache-${Buffer.from(`${repoName}:${cachePath}`).toString("base64url").slice(0, 24)}`;
@@ -490,6 +518,70 @@ async function execInContainer(
return { log, exitCode: inspectData.ExitCode ?? 1 };
}
/**
* Copy the bare repo into the container at CONTAINER_REPO_PATH.
*
* Not a bind mount: the Docker daemon resolves bind sources in the host
* filesystem, so when HearthForge itself runs in a container it finds nothing
* at our DATA_DIR path and silently mounts an empty directory instead.
*/
async function uploadRepo(
containerId: string,
repoAbsPath: string,
): Promise<void> {
const mk = await execInContainer(containerId, [
"mkdir",
"-p",
CONTAINER_REPO_PATH,
]);
if (mk.exitCode !== 0) {
throw new Error(
`Failed to create ${CONTAINER_REPO_PATH} in the container: ${mk.log.trim()}`,
);
}
// Flatten ownership: host uids mean nothing here and trip git's
// ownership check on the clone below.
const tar = Bun.spawn(
[
"tar",
"-cf",
"-",
"--owner=0",
"--group=0",
"--numeric-owner",
"-C",
repoAbsPath,
".",
],
{ stdout: "pipe", stderr: "pipe" },
);
const resp = await dockerFetch(
`/containers/${containerId}/archive?path=${encodeURIComponent(CONTAINER_REPO_PATH)}`,
{
method: "PUT",
headers: { "Content-Type": "application/x-tar" },
body: tar.stdout,
},
);
const tarExit = await tar.exited;
if (!resp.ok) {
const detail = (await resp.text().catch(() => "")).trim();
throw new Error(
`Failed to upload the repository: HTTP ${resp.status}${detail ? ` ${detail}` : ""}`,
);
}
await resp.body?.cancel();
if (tarExit !== 0) {
const err = (await new Response(tar.stderr).text()).trim();
throw new Error(
`Failed to read the repository: tar exited ${tarExit}${err ? ` ${err}` : ""}`,
);
}
}
async function removeContainer(containerId: string): Promise<void> {
try {
const resp = await dockerFetch(
@@ -736,6 +828,10 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
if (!cfg) throw new Error("Failed to parse .hearthforge-ci.toml");
const invalid = validateCiConfig(cfg);
if (invalid)
throw new Error(`Invalid .hearthforge-ci.toml: ${invalid}`);
// Load secrets for log masking
const secrets = await db
.selectFrom("ci_secrets")
@@ -779,13 +875,7 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) throw new Error("Cancelled");
// Create + start container
containerId = await createContainer(
runId,
repo.name,
cfg,
repoPath(repo.name),
envArray,
);
containerId = await createContainer(runId, repo.name, cfg, envArray);
runningTasks.get(runId)!.containerId = containerId;
await startContainer(containerId);
@@ -798,16 +888,25 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
// Clone project if requested
if (cfg.clone_project_to && run.commit_sha) {
await execInContainer(
await uploadRepo(containerId, repoPath(repo.name));
const clone = await execInContainer(
containerId,
[
"sh",
"-c",
`git clone /hearthforge-repo.git ${cfg.clone_project_to} && git -C ${cfg.clone_project_to} checkout --detach ${run.commit_sha}`,
// safe.directory: the upload lands as root, but the step
// user is whatever the image defaults to. Without this git
// refuses the repo as "dubious ownership".
`git -c safe.directory=${CONTAINER_REPO_PATH} clone ${CONTAINER_REPO_PATH} ${cfg.clone_project_to} && git -C ${cfg.clone_project_to} checkout --detach ${run.commit_sha}`,
],
cfg.work_dir,
envArray,
);
if (clone.exitCode !== 0) {
throw new Error(
`Failed to clone the repository into ${cfg.clone_project_to}: ${clone.log.trim()}`,
);
}
}
// Execute steps
@@ -854,7 +953,7 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
// Handle clear option
if (step.clear && cfg.clone_project_to && run.commit_sha) {
await execInContainer(
const cleared = await execInContainer(
containerId,
[
"sh",
@@ -864,6 +963,11 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
cfg.work_dir,
envArray,
);
if (cleared.exitCode !== 0) {
throw new Error(
`Failed to reset ${cfg.clone_project_to} before step "${step.name}": ${cleared.log.trim()}`,
);
}
}
await db
@@ -987,18 +1091,22 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
: "Skipped: run failed";
if (!isDockerUnavailable) {
// Write error to a synthetic step if we have no steps yet
const hasSteps = await db
// A failed step already carries its own log. Failures outside any
// step (validation, upload, clone) would leave no reason at all.
const failedStep = await db
.selectFrom("ci_steps")
.select("id")
.where("run_id", "=", runId)
.where("status", "=", "failure")
.executeTakeFirst();
if (!hasSteps) {
if (!failedStep) {
await db
.insertInto("ci_steps")
.values({
run_id: runId,
name: "setup",
// Not "setup": a pipeline may well have its own step
// by that name.
name: "pipeline setup",
status: "failure",
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
Mtests/ci.unit.test.ts
@@ -3,6 +3,7 @@ import {
parseCiConfig,
shouldTriggerPush,
shouldTriggerTag,
validateCiConfig,
} from "../src/services/ci.ts";
describe("parseCiConfig", () => {
@@ -189,3 +190,51 @@ describe("shouldTriggerTag", () => {
expect(shouldTriggerTag(c)).toBe(false);
});
});
describe("validateCiConfig", () => {
const parse = (toml: string) => parseCiConfig(toml)!;
test("accepts a cache path outside the clone directory", () => {
const cfg = parse(`
image = "alpine"
clone_project_to = "/ci/build/project"
cache = ["/ci/cache/target", "/usr/local/cargo/registry"]
`);
expect(validateCiConfig(cfg)).toBeNull();
});
test("rejects a cache path inside the clone directory", () => {
const cfg = parse(`
image = "alpine"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project/target"]
`);
expect(validateCiConfig(cfg)).toContain("/ci/build/project/target");
});
test("rejects a cache path equal to the clone directory", () => {
const cfg = parse(`
image = "alpine"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project"]
`);
expect(validateCiConfig(cfg)).not.toBeNull();
});
test("does not treat a sibling prefix as inside", () => {
const cfg = parse(`
image = "alpine"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project-cache"]
`);
expect(validateCiConfig(cfg)).toBeNull();
});
test("ignores cache when nothing is cloned", () => {
const cfg = parse(`
image = "alpine"
cache = ["/anything"]
`);
expect(validateCiConfig(cfg)).toBeNull();
});
});
Mtests/e2e.ci.test.ts
@@ -38,6 +38,10 @@ interface ExecResp {
exitCode: number;
}
// Repo archive uploads (PUT /containers/*/archive)
const uploads: Array<{ path: string; bytes: number }> = [];
// Body of the last POST /containers/create
let lastCreateBody: Record<string, any> | null = null;
// Per-exec-ID response map, populated when exec is created
const execMap = new Map<string, ExecResp>();
// Queue consumed in order when execs are created — allows tests to pre-program
@@ -53,6 +57,8 @@ function resetMock() {
execMap.clear();
execQueue.length = 0;
execCounter = 0;
uploads.length = 0;
lastCreateBody = null;
}
/** Build a Docker multiplexed stream frame from a string. */
@@ -93,7 +99,7 @@ function startMockDocker() {
rmSync(SOCKET_PATH, { force: true });
mockServer = Bun.serve({
unix: SOCKET_PATH,
fetch(req: Request): Response {
async fetch(req: Request): Promise<Response> {
const p = new URL(req.url).pathname;
const qs = new URL(req.url).searchParams;
@@ -107,6 +113,7 @@ function startMockDocker() {
}
// Create container
if (req.method === "POST" && /\/containers\/create/.test(p)) {
lastCreateBody = (await req.json()) as Record<string, any>;
return Response.json({ Id: "mock-ctr-001" });
}
// Start container
@@ -143,6 +150,18 @@ function startMockDocker() {
const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
return Response.json({ ExitCode: resp.exitCode });
}
// Archive upload (used to copy the bare repo into the container)
if (
req.method === "PUT" &&
/\/containers\/[^/]+\/archive/.test(p)
) {
const body = new Uint8Array(await req.arrayBuffer());
uploads.push({
path: qs.get("path") ?? "",
bytes: body.length,
});
return new Response(null, { status: 200 });
}
// Archive (used by publish_file artifact collection)
if (
req.method === "GET" &&
@@ -902,3 +921,102 @@ describe("purge cache", () => {
}
});
});
describe("repo upload", () => {
const CLONE_TOML = `
image = "debian:latest"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
[on]
manual = true
[hello]
run_sh = "echo hi"
`;
let cloneSha: string;
beforeAll(() => {
cloneSha = seedCiToml("ci-repo", CLONE_TOML);
});
function trigger(): Promise<number> {
return triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: cloneSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
}
test("bare repo is uploaded instead of bind-mounted", async () => {
const runId = await trigger();
expect(await waitForRun(runId)).toBe("success");
expect(uploads.map((u) => u.path)).toContain("/hearthforge-repo.git");
expect(uploads[0]!.bytes).toBeGreaterThan(0);
const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []);
expect(binds).not.toContain("hearthforge-repo.git");
});
test("a failing clone fails the run before any step runs", async () => {
queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
queueExec({ output: "", exitCode: 0 }); // mkdir repo path
queueExec({ output: "fatal: not empty\n", exitCode: 128 }); // clone
const runId = await trigger();
expect(await waitForRun(runId)).toBe("failure");
const step = await db
.selectFrom("ci_steps")
.select("status")
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("skipped");
const setup = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "pipeline setup")
.executeTakeFirst();
expect(setup?.status).toBe("failure");
expect(setup?.log).toContain("fatal: not empty");
});
test("a cache path inside the clone directory is rejected", async () => {
const badSha = seedCiToml(
"ci-repo",
`
image = "debian:latest"
clone_project_to = "/ci/build/project"
cache = ["/ci/build/project/target"]
[on]
manual = true
[hello]
run_sh = "echo hi"
`,
);
const runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: badSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
expect(await waitForRun(runId)).toBe("failure");
expect(uploads).toHaveLength(0);
const setup = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "pipeline setup")
.executeTakeFirst();
expect(setup?.log).toContain("is inside clone_project_to");
});
});