add pipelines

AuthorKonata <konata@posteo.jp>
Date
Commit97fe5b8d453cde741c96ba6c0ac79abdcd6239d6
Parent4ec25cd
30 files changed, 3470 insertions(+), 24 deletions(-)
▾MREADME.md
@@ -106,5 +106,4 @@ bun run test # E2E and unit tests (uses Playwright; don't call bun test
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- More repository manipulation through the UI — file/directory/branch creation, renaming, and deletion
- Remove test retry logic once Bun no longer randomly stalls
- Remove test retry logic once Bun no longer randomly stalls
▾Mbun.lock
@@ -19,6 +19,7 @@
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"smol-toml": "^1.6.1",
"ssh2": "^1.17.0",
},
"devDependencies": {
@@ -407,6 +408,8 @@
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
▾Mpackage.json
@@ -45,6 +45,7 @@
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"smol-toml": "^1.6.1",
"ssh2": "^1.17.0"
}
}
▾Apublic/assets/hearthforge-ci-template.toml
@@ -0,0 +1,51 @@
# HearthForge CI Pipeline Template
# Place this file at .hearthforge-ci.toml in your repository root.
image = "docker.io/debian:stable"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
# shell = ["/bin/sh", "-c"]
shell_setup = "set -euo pipefail"
# timeout = 3600 # overall run timeout in seconds
# cpu_limit = 2.0 # CPU cores limit
# memory_limit = "2g" # memory limit (k/m/g suffix)
# cache = ["/root/.cargo", "/root/.npm"] # persist between runs
[on]
push = ["main"] # trigger on push to these branches; use ["*"] for all
tag = false # trigger on tag push
manual = true # allow manual trigger from the UI
[variables]
# [variables.MY_VAR]
# default = "hello"
# description = "A custom variable, overridable from the UI"
# Steps are executed in file order. Each [section] is one step.
# Reserved section names: [on], [variables]
[setup]
run_sh = "apt-get update -qq && apt-get install -y --no-install-recommends build-essential"
timeout = 180
[build]
run_sh = "make -C project all"
[test]
# run_if is a shell expression; the step is skipped if it returns non-zero
# run_if = 'test -n "${CI_COMMIT_TAG}"'
run_sh = "make -C project test"
[package]
run_sh = "make -C project dist"
# Publish artifacts — these can appear in any step
# publish_file: copy a single file directly
# publish_file = ["/ci/build/project/dist/my-binary"]
# publish_gzip: create a .tar.gz archive (requires tar in image)
# publish_gzip = ["/ci/build/project/dist/"]
# publish_tar: create a plain .tar archive
# publish_tar = ["/ci/build/project/dist/"]
# publish_zip: create a .zip archive (requires zip in image)
# publish_zip = ["/ci/build/project/dist/"]
# publish_zstd: create a .tar.zst archive (requires tar + zstd in image)
# publish_zstd = ["/ci/build/project/dist/"]
▾Mscripts/test.ts
@@ -8,7 +8,7 @@ const files = readdirSync(testsDir)
.sort();
const STALL_TIMEOUT = 20_000; // kill if no output for 20s
const MAX_RETRIES = 2;
const MAX_RETRIES = 3;
// retry logic needed because tests get randomly get stuck on startup with bun
// strace shows bun completely spinning in futex and not doing anything else
function runTest(filePath: string): Promise<boolean> {
▾Msrc/app.ts
@@ -4,6 +4,7 @@ import { Elysia } from "elysia";
import config from "./config.ts";
import { authRoutes } from "./routes/auth.tsx";
import { avatarRoutes } from "./routes/avatars.ts";
import { ciRoutes } from "./routes/ci.tsx";
import { gitRoutes } from "./routes/git.ts";
import { issueRoutes } from "./routes/issues.tsx";
import { patchRoutes } from "./routes/patches.tsx";
@@ -30,6 +31,7 @@ export async function createApp(port: number) {
.use(issueRoutes)
.use(patchRoutes)
.use(releasesRoutes)
.use(ciRoutes)
.use(avatarRoutes)
.listen(port);
}
▾Msrc/config.ts
@@ -31,6 +31,10 @@ const config = {
MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000,
MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64,
MAX_PASSWORD_BYTES: parseInt(env.MAX_PASSWORD_BYTES ?? "", 10) || 1024,
CI_DOCKER_SOCKET: env.CI_DOCKER_SOCKET ?? "",
CI_MAX_HISTORY: parseInt(env.CI_MAX_HISTORY ?? "", 10) || 50,
CI_MAX_CONCURRENT: parseInt(env.CI_MAX_CONCURRENT ?? "", 10) || 2,
CI_DEFAULT_TIMEOUT: parseInt(env.CI_DEFAULT_TIMEOUT ?? "", 10) || 3600,
};
// Derived values that depend on other config fields
▾Msrc/constants.ts
@@ -64,6 +64,7 @@ export const COMMITS_PER_PAGE = 20;
export const ISSUES_PER_PAGE = 20;
export const PATCHES_PER_PAGE = 20;
export const RELEASES_PER_PAGE = 20;
export const CI_RUNS_PER_PAGE = 20;
export const BRANCHES_PER_PAGE = 30;
export const TAGS_PER_PAGE = 30;
@@ -99,4 +100,7 @@ export const paths = {
get ALLOWED_SIGNERS_PATH() {
return path.join(config.DATA_DIR, "allowed_signers");
},
get CI_ARTIFACTS_DIR() {
return path.join(config.DATA_DIR, "ci", "artifacts");
},
};
▾Msrc/db/index.ts
@@ -154,6 +154,49 @@ interface PatchLabelTable {
label_id: number;
}
interface CiRunTable {
id: Generated<number>;
repo_id: number;
triggered_by: number | null;
trigger_source: string;
commit_sha: string | null;
commit_branch: string | null;
commit_tag: string | null;
status: string;
variable_overrides: string | null;
started_at: string | null;
finished_at: string | null;
created_at: Generated<string>;
}
interface CiStepTable {
id: Generated<number>;
run_id: number;
name: string;
status: string;
exit_code: number | null;
started_at: string | null;
finished_at: string | null;
log: Generated<string>;
}
interface CiArtifactTable {
id: Generated<number>;
run_id: number;
filename: string;
size: number;
created_at: Generated<string>;
}
interface CiSecretTable {
id: Generated<number>;
repo_id: number;
name: string;
value: string;
description: string | null;
created_at: Generated<string>;
}
export interface Database {
users: UserTable;
passkeys: PasskeyTable;
@@ -171,6 +214,10 @@ export interface Database {
labels: LabelTable;
issue_labels: IssueLabelTable;
patch_labels: PatchLabelTable;
ci_runs: CiRunTable;
ci_steps: CiStepTable;
ci_artifacts: CiArtifactTable;
ci_secrets: CiSecretTable;
}
// Selectable row types (id is plain number, as returned by queries)
@@ -188,6 +235,10 @@ export type SshKeyRow = Selectable<SshKeyTable>;
export type ReleaseRow = Selectable<ReleaseTable>;
export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
export type LabelRow = Selectable<LabelTable>;
export type CiRunRow = Selectable<CiRunTable>;
export type CiStepRow = Selectable<CiStepTable>;
export type CiArtifactRow = Selectable<CiArtifactTable>;
export type CiSecretRow = Selectable<CiSecretTable>;
let sqlite = new BunDatabase(paths.DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
@@ -236,6 +287,48 @@ export function resetDb() {
});
}
// Migration: create CI tables if missing
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
trigger_source TEXT NOT NULL,
commit_sha TEXT,
commit_branch TEXT,
commit_tag TEXT,
status TEXT NOT NULL DEFAULT 'pending',
variable_overrides TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
exit_code INTEGER,
started_at TEXT,
finished_at TEXT,
log TEXT NOT NULL DEFAULT ''
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_id, name)
)`);
// Migration: add allow_user_labels column to repositories if missing
const repoCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(repositories)")
▾Msrc/db/schema.sql
@@ -157,3 +157,47 @@ CREATE TABLE IF NOT EXISTS patch_labels (
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (patch_id, label_id)
);
CREATE TABLE IF NOT EXISTS ci_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
trigger_source TEXT NOT NULL,
commit_sha TEXT,
commit_branch TEXT,
commit_tag TEXT,
status TEXT NOT NULL DEFAULT 'pending',
variable_overrides TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ci_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
exit_code INTEGER,
started_at TEXT,
finished_at TEXT,
log TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ci_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ci_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_id, name)
);
▾Msrc/routes/auth.tsx
@@ -151,7 +151,13 @@ export const authRoutes = new Elysia()
status: 403,
});
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS)) {
if (
!checkRateLimit(
ip,
REGISTRATION_MAX_ATTEMPTS,
REGISTRATION_RATE_WINDOW_MS,
)
) {
return html(
<Register
error="Too many registration attempts. Please try again later."
▾Asrc/routes/ci.tsx
@@ -0,0 +1,506 @@
import { createReadStream, existsSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import {
cancelRun,
parseCiConfig,
shouldTriggerPush,
shouldTriggerTag,
triggerRun,
} from "../services/ci.ts";
import { git } from "../services/git.ts";
import { CiHistory } from "../views/ci/CiHistory.tsx";
import { CiRunDetail } from "../views/ci/CiRunDetail.tsx";
import { html } from "../views/render.tsx";
/** Generate an SVG badge for CI status */
function makeBadge(status: string): string {
const colors: Record<string, string> = {
success: "#4c1",
failure: "#e05d44",
running: "#007ec6",
pending: "#9f9f9f",
cancelled: "#9f9f9f",
};
const color = colors[status] ?? "#9f9f9f";
const label = "pipeline";
const value = status;
const labelWidth = label.length * 6 + 10;
const valueWidth = value.length * 6 + 10;
const totalWidth = labelWidth + valueWidth;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="${totalWidth}" height="20" rx="3"/></clipPath>
<g clip-path="url(#r)">
<rect width="${labelWidth}" height="20" fill="#555"/>
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
<text x="${labelWidth / 2}" y="14">${label}</text>
<text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${value}</text>
<text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
</g>
</svg>`;
}
export const ciRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
// Badge — no auth required for public repos
.get("/:repo/ci/badge.svg", async ({ params }) => {
const repo = await db
.selectFrom("repositories")
.select(["id", "is_private"])
.where("name", "=", params.repo)
.executeTakeFirst();
if (!repo || repo.is_private) {
return new Response("Not found", { status: 404 });
}
const latestRun = await db
.selectFrom("ci_runs")
.select("status")
.where("repo_id", "=", repo.id)
.orderBy("id", "desc")
.limit(1)
.executeTakeFirst();
const status = latestRun?.status ?? "no builds";
return new Response(makeBadge(status), {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "no-cache",
},
});
})
// Run history
.get(
"/:repo/ci",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const page = Math.max(1, query.page ?? 1);
const countRow = await db
.selectFrom("ci_runs")
.select(db.fn.countAll<number>().as("count"))
.where("repo_id", "=", repo.id)
.executeTakeFirst();
const totalPages = Math.max(
1,
Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * CI_RUNS_PER_PAGE;
const runs = await db
.selectFrom("ci_runs")
.leftJoin("users", "users.id", "ci_runs.triggered_by")
.select([
"ci_runs.id",
"ci_runs.status",
"ci_runs.trigger_source",
"ci_runs.commit_sha",
"ci_runs.commit_branch",
"ci_runs.commit_tag",
"ci_runs.started_at",
"ci_runs.finished_at",
"ci_runs.created_at",
"users.username as triggered_by_username",
])
.where("repo_id", "=", repo.id)
.orderBy("ci_runs.id", "desc")
.limit(CI_RUNS_PER_PAGE)
.offset(offset)
.execute();
// Artifact counts per run
const runIds = runs.map((r) => r.id);
const artifactCounts =
runIds.length > 0
? await db
.selectFrom("ci_artifacts")
.select([
"run_id",
db.fn.countAll<number>().as("count"),
])
.where("run_id", "in", runIds)
.groupBy("run_id")
.execute()
: [];
const artifactCountMap = new Map(
artifactCounts.map((r) => [r.run_id, Number(r.count)]),
);
const runsWithCounts = runs.map((r) => ({
...r,
artifact_count: artifactCountMap.get(r.id) ?? 0,
}));
// Determine why manual trigger may be unavailable (admin-only check)
let manualTriggerDisabledReason: string | null = null;
if (user?.isAdmin) {
const branches = await git.branches(repo.name);
const defaultBranch = repo.default_branch || branches[0];
if (!defaultBranch) {
manualTriggerDisabledReason = "No branches — push a commit first";
} else {
const headLog = await git.log(repo.name, defaultBranch, 1);
if (!headLog.length) {
manualTriggerDisabledReason = "No commits yet";
} else {
const tomlBuf = await git.show(
repo.name,
headLog[0]!.hash,
".hearthforge-ci.toml",
);
if (!tomlBuf) {
manualTriggerDisabledReason =
"No .hearthforge-ci.toml found in repository";
} else {
const cfg = parseCiConfig(
tomlBuf.toString("utf-8"),
);
if (!cfg) {
manualTriggerDisabledReason =
"Failed to parse .hearthforge-ci.toml";
} else if (!cfg.on?.manual) {
manualTriggerDisabledReason =
'Add manual = true under [on] to enable manual runs';
}
}
}
}
}
return html(
<CiHistory
user={user}
repo={repo}
runs={runsWithCounts}
pagination={{
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/ci?page={page}`,
}}
manualTriggerDisabledReason={manualTriggerDisabledReason}
/>,
);
},
{ query: t.Object({ page: t.Optional(t.Number()) }) },
)
// Run detail
.get("/:repo/ci/:runId", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const runId = Number(params.runId);
const run = await db
.selectFrom("ci_runs")
.leftJoin("users", "users.id", "ci_runs.triggered_by")
.select([
"ci_runs.id",
"ci_runs.status",
"ci_runs.trigger_source",
"ci_runs.commit_sha",
"ci_runs.commit_branch",
"ci_runs.commit_tag",
"ci_runs.variable_overrides",
"ci_runs.started_at",
"ci_runs.finished_at",
"ci_runs.created_at",
"users.username as triggered_by_username",
])
.where("ci_runs.id", "=", runId)
.where("ci_runs.repo_id", "=", repo.id)
.executeTakeFirst();
if (!run) return new Response("Not found", { status: 404 });
const steps = await db
.selectFrom("ci_steps")
.selectAll()
.where("run_id", "=", runId)
.orderBy("id", "asc")
.execute();
const artifacts = await db
.selectFrom("ci_artifacts")
.selectAll()
.where("run_id", "=", runId)
.orderBy("id", "asc")
.execute();
return html(
<CiRunDetail
user={user}
repo={repo}
run={run}
steps={steps}
artifacts={artifacts}
/>,
);
})
// Manual trigger
.post("/:repo/ci/run", async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
// Read CI config at HEAD to check manual trigger is allowed and get variable definitions
const branches = await git.branches(repo.name);
const defaultBranch = repo.default_branch || branches[0];
if (!defaultBranch) return new Response("No branches", { status: 400 });
const headLog = await git.log(repo.name, defaultBranch, 1);
if (!headLog.length) return new Response("No commits", { status: 400 });
const headSha = headLog[0]!.hash;
const tomlBuf = await git.show(
repo.name,
headSha,
".hearthforge-ci.toml",
);
if (!tomlBuf)
return new Response(
"No .hearthforge-ci.toml found at HEAD. Add one to your repository to use CI pipelines.",
{ status: 400 },
);
const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
if (!cfg)
return new Response(
"Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
{ status: 400 },
);
if (!cfg.on?.manual)
return new Response(
'Manual triggers are not enabled. Add manual = true under [on] in .hearthforge-ci.toml.',
{ status: 400 },
);
// Parse variable overrides from form body
const variableOverrides: Record<string, string> = {};
if (cfg.variables) {
for (const varName of Object.keys(cfg.variables)) {
const formKey = `var_${varName}`;
const val = (body as Record<string, string>)[formKey];
if (typeof val === "string") {
variableOverrides[varName] = val;
}
}
}
const runId = await triggerRun(repo.name, {
triggerSource: "manual",
commitSha: headSha,
commitBranch: defaultBranch,
triggeredBy: user!.id,
variableOverrides,
});
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/ci/${runId}` },
});
})
// Retry
.post("/:repo/ci/:runId/retry", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const runId = Number(params.runId);
const original = await db
.selectFrom("ci_runs")
.selectAll()
.where("id", "=", runId)
.where("repo_id", "=", repo.id)
.executeTakeFirst();
if (!original) return new Response("Not found", { status: 404 });
const newRunId = await triggerRun(repo.name, {
triggerSource: original.trigger_source as "push" | "tag" | "manual",
commitSha: original.commit_sha ?? "",
commitBranch: original.commit_branch ?? undefined,
commitTag: original.commit_tag ?? undefined,
triggeredBy: user!.id,
variableOverrides: original.variable_overrides
? JSON.parse(original.variable_overrides)
: undefined,
});
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/ci/${newRunId}` },
});
})
// Cancel
.post("/:repo/ci/:runId/cancel", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const runId = Number(params.runId);
const run = await db
.selectFrom("ci_runs")
.select("id")
.where("id", "=", runId)
.where("repo_id", "=", repo.id)
.executeTakeFirst();
if (!run) return new Response("Not found", { status: 404 });
await cancelRun(runId);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/ci/${runId}` },
});
})
// Create secret
.post("/:repo/settings/ci-secrets", async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await db
.selectFrom("repositories")
.select("id")
.where("name", "=", params.repo)
.executeTakeFirst();
if (!repo) return new Response("Not found", { status: 404 });
const name = (body as Record<string, string>).name?.trim();
const value = (body as Record<string, string>).value;
const description =
(body as Record<string, string>).description?.trim() || null;
if (!name || !/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
return new Response(null, {
status: 302,
headers: {
Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret name must be a valid identifier.")}`,
},
});
}
if (!value) {
return new Response(null, {
status: 302,
headers: {
Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret value cannot be empty.")}`,
},
});
}
await db
.insertInto("ci_secrets")
.values({
repo_id: repo.id,
name,
value,
description,
})
.onConflict((oc) =>
oc
.columns(["repo_id", "name"])
.doUpdateSet({ value, description }),
)
.execute();
return new Response(null, {
status: 302,
headers: {
Location: `/${params.repo}/settings?success=Secret+saved.`,
},
});
})
// Delete secret
.post(
"/:repo/settings/ci-secrets/delete",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await db
.selectFrom("repositories")
.select("id")
.where("name", "=", params.repo)
.executeTakeFirst();
if (!repo) return new Response("Not found", { status: 404 });
const id = Number((body as Record<string, string>).id);
await db
.deleteFrom("ci_secrets")
.where("id", "=", id)
.where("repo_id", "=", repo.id)
.execute();
return new Response(null, {
status: 302,
headers: {
Location: `/${params.repo}/settings?success=Secret+deleted.`,
},
});
},
)
// Artifact download
.get(
"/:repo/ci/:runId/artifacts/:artifactId",
async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const runId = Number(params.runId);
const artifactId = Number(params.artifactId);
const artifact = await db
.selectFrom("ci_artifacts")
.innerJoin("ci_runs", "ci_runs.id", "ci_artifacts.run_id")
.select([
"ci_artifacts.id",
"ci_artifacts.filename",
"ci_artifacts.size",
])
.where("ci_artifacts.id", "=", artifactId)
.where("ci_runs.id", "=", runId)
.where("ci_runs.repo_id", "=", repo.id)
.executeTakeFirst();
if (!artifact) return new Response("Not found", { status: 404 });
const filePath = path.join(
paths.CI_ARTIFACTS_DIR,
String(runId),
artifact.filename,
);
if (!existsSync(filePath))
return new Response("File not found", { status: 404 });
return new Response(Bun.file(filePath), {
headers: {
"Content-Disposition": `attachment; filename="${artifact.filename}"`,
"Content-Type": "application/octet-stream",
"Content-Length": String(artifact.size),
},
});
},
);
▾Msrc/routes/git.ts
@@ -4,7 +4,83 @@ import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
import { db } from "../db";
import { invalidateRefCache } from "../services/git.ts";
import {
parseCiConfig,
shouldTriggerPush,
shouldTriggerTag,
triggerRun,
} from "../services/ci.ts";
import { git, invalidateRefCache } from "../services/git.ts";
/** Parse ref updates from git receive-pack request body (pkt-line format). */
function parseRefUpdates(
body: Uint8Array,
): Array<{ oldSha: string; newSha: string; refname: string }> {
const text = new TextDecoder().decode(body.slice(0, 4096));
const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
let pos = 0;
while (pos + 4 <= text.length) {
const lenStr = text.slice(pos, pos + 4);
const len = parseInt(lenStr, 16);
if (Number.isNaN(len) || len === 0) break;
if (len < 4 || pos + len > text.length) break;
// Strip capabilities (after first NUL) and trim
const line = text
.slice(pos + 4, pos + len)
.replace(/\0.*$/, "")
.trim();
pos += len;
const parts = line.split(" ");
if (parts.length >= 3) {
const oldSha = parts[0] ?? "";
const newSha = parts[1] ?? "";
const refname = parts[2] ?? "";
if (refname) refs.push({ oldSha, newSha, refname });
}
}
return refs;
}
/** Fire CI runs for any updated refs that match the pipeline config. */
async function triggerCiForPush(
repoName: string,
refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
): Promise<void> {
for (const { newSha, refname } of refUpdates) {
// Skip deletions
if (/^0+$/.test(newSha)) continue;
const isBranch = refname.startsWith("refs/heads/");
const isTag = refname.startsWith("refs/tags/");
if (!isBranch && !isTag) continue;
const tomlBuf = await git
.show(repoName, newSha, ".hearthforge-ci.toml")
.catch(() => null);
if (!tomlBuf) continue;
const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
if (!cfg) continue;
if (isBranch) {
const branch = refname.slice("refs/heads/".length);
if (shouldTriggerPush(cfg, branch)) {
triggerRun(repoName, {
triggerSource: "push",
commitSha: newSha,
commitBranch: branch,
}).catch(() => {});
}
} else if (isTag && shouldTriggerTag(cfg)) {
const tag = refname.slice("refs/tags/".length);
triggerRun(repoName, {
triggerSource: "tag",
commitSha: newSha,
commitTag: tag,
}).catch(() => {});
}
}
}
function pktLine(str: string): Buffer {
const len = Buffer.byteLength(str, "utf-8") + 4;
@@ -51,7 +127,7 @@ function unauthorized(): Response {
async function getRepo(
slug: string,
): Promise<{ repoPath: string; isPrivate: boolean } | null> {
): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> {
const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
if (!VALID_REPO_NAME_RE.test(repoName)) return null;
const repo = await db
@@ -62,7 +138,7 @@ async function getRepo(
if (!repo) return null;
const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
if (!existsSync(repoPath)) return null;
return { repoPath, isPrivate: repo.is_private === 1 };
return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
}
async function spawnGit(
@@ -165,11 +241,14 @@ export const gitRoutes = new Elysia()
const repo = await getRepo(params.repo);
if (!repo) return new Response("Not Found", { status: 404 });
const body = new Uint8Array(await request.arrayBuffer());
const refUpdates = parseRefUpdates(body);
const result = await spawnGit(
["git", "receive-pack", "--stateless-rpc", repo.repoPath],
body,
);
invalidateRefCache(repo.name);
// Trigger CI in background — don't block the git push response
triggerCiForPush(repo.name, refUpdates).catch(() => {});
return new Response(result, {
headers: {
"Content-Type": "application/x-git-receive-pack-result",
▾Msrc/routes/repos.tsx
@@ -757,12 +757,20 @@ export const repoRoutes = new Elysia()
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const branches = await git.branches(repo.name);
const labels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
const [labels, secrets] = await Promise.all([
db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute(),
db
.selectFrom("ci_secrets")
.select(["id", "name", "description", "created_at"])
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute(),
]);
const success =
typeof query.success === "string" ? query.success : undefined;
const error = typeof query.error === "string" ? query.error : undefined;
@@ -772,6 +780,7 @@ export const repoRoutes = new Elysia()
repo={repo}
branches={branches}
labels={labels}
secrets={secrets}
success={success}
error={error}
/>,
▾Asrc/services/ci.ts
@@ -0,0 +1,980 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import config from "../config.ts";
import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
import { db } from "../db/index.ts";
import { repoPath } from "./git.ts";
// --- Types ---
interface CiVariableDef {
default?: string;
description?: string;
}
interface CiStepConfig {
run_sh?: string;
run_if?: string;
clear?: boolean;
timeout?: number;
publish_file?: string | string[];
publish_tar?: string | string[];
publish_gzip?: string | string[];
publish_zip?: string | string[];
publish_zstd?: string | string[];
}
export interface CiStep extends CiStepConfig {
name: string;
}
export interface CiConfig {
image: string;
work_dir?: string;
clone_project_to?: string;
shell?: string[];
shell_setup?: string;
timeout?: number;
cpu_limit?: number;
memory_limit?: string;
cache?: string[];
on?: {
push?: string[] | boolean;
tag?: boolean;
manual?: boolean;
};
variables?: Record<string, CiVariableDef>;
steps: CiStep[];
}
export interface TriggerOpts {
triggerSource: "push" | "tag" | "manual";
commitSha: string;
commitBranch?: string;
commitTag?: string;
triggeredBy?: number;
variableOverrides?: Record<string, string>;
}
// Reserved TOML table names that are not steps
const RESERVED_TABLES = new Set(["on", "variables"]);
// In-memory map of running tasks for cancellation
const runningTasks = new Map<
number,
{ controller: AbortController; containerId?: string }
>();
// --- TOML Parsing ---
export function parseCiConfig(tomlStr: string): CiConfig | null {
let raw: Record<string, unknown>;
try {
raw = parseToml(tomlStr) as Record<string, unknown>;
} catch {
return null;
}
const image = raw.image;
if (typeof image !== "string" || !image) return null;
const steps: CiStep[] = [];
for (const [key, val] of Object.entries(raw)) {
if (RESERVED_TABLES.has(key)) continue;
if (typeof val !== "object" || val === null || Array.isArray(val))
continue;
// It's a table section — treat as a step
const stepCfg = val as Record<string, unknown>;
steps.push({ name: key, ...(stepCfg as CiStepConfig) });
}
const rawOn = raw.on as Record<string, unknown> | undefined;
const rawVars = raw.variables as
| Record<string, Record<string, unknown>>
| undefined;
const variables: Record<string, CiVariableDef> = {};
if (rawVars) {
for (const [name, def] of Object.entries(rawVars)) {
if (typeof def === "object" && def !== null) {
variables[name] = {
default:
typeof def.default === "string"
? def.default
: undefined,
description:
typeof def.description === "string"
? def.description
: undefined,
};
}
}
}
return {
image,
work_dir: typeof raw.work_dir === "string" ? raw.work_dir : undefined,
clone_project_to:
typeof raw.clone_project_to === "string"
? raw.clone_project_to
: undefined,
shell: Array.isArray(raw.shell) ? (raw.shell as string[]) : undefined,
shell_setup:
typeof raw.shell_setup === "string" ? raw.shell_setup : undefined,
timeout: typeof raw.timeout === "number" ? raw.timeout : undefined,
cpu_limit:
typeof raw.cpu_limit === "number" ? raw.cpu_limit : undefined,
memory_limit:
typeof raw.memory_limit === "string" ? raw.memory_limit : undefined,
cache: Array.isArray(raw.cache) ? (raw.cache as string[]) : undefined,
on: rawOn
? {
push: Array.isArray(rawOn.push)
? (rawOn.push as string[])
: typeof rawOn.push === "boolean"
? rawOn.push
: undefined,
tag: typeof rawOn.tag === "boolean" ? rawOn.tag : undefined,
manual:
typeof rawOn.manual === "boolean"
? rawOn.manual
: undefined,
}
: undefined,
variables,
steps,
};
}
// --- Trigger matching ---
export function shouldTriggerPush(cfg: CiConfig, branch: string): boolean {
const pushCfg = cfg.on?.push;
if (!pushCfg) return false;
if (pushCfg === true) return true;
if (Array.isArray(pushCfg)) {
return pushCfg.some((pattern) => matchGlob(pattern, branch));
}
return false;
}
export function shouldTriggerTag(cfg: CiConfig): boolean {
return cfg.on?.tag === true;
}
function matchGlob(pattern: string, value: string): boolean {
if (pattern === "*") return true;
const re = new RegExp(
`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`,
);
return re.test(value);
}
// --- Docker socket ---
let resolvedSocket: string | null = null;
async function getSocket(): Promise<string> {
if (resolvedSocket) return resolvedSocket;
if (config.CI_DOCKER_SOCKET) {
resolvedSocket = config.CI_DOCKER_SOCKET;
return resolvedSocket;
}
const uid = process.getuid?.();
const candidates = [
"/var/run/docker.sock",
"/run/podman/podman.sock",
...(uid !== undefined ? [`/run/user/${uid}/podman/podman.sock`] : []),
];
for (const s of candidates) {
if (existsSync(s)) {
resolvedSocket = s;
return s;
}
}
throw new Error(
"No Docker/Podman socket found. Set CI_DOCKER_SOCKET env var.",
);
}
async function dockerFetch(
endpoint: string,
init?: RequestInit,
): Promise<Response> {
const socket = await getSocket();
return fetch(`http://localhost/v1.47${endpoint}`, {
...init,
unix: socket,
});
}
// --- Docker helpers ---
function splitImageRef(image: string): { name: string; tag: string } {
const lastColon = image.lastIndexOf(":");
if (lastColon < 0) return { name: image, tag: "latest" };
const possibleTag = image.slice(lastColon + 1);
if (possibleTag.includes("/")) return { name: image, tag: "latest" };
return { name: image.slice(0, lastColon), tag: possibleTag };
}
async function pullImage(image: string): Promise<void> {
const { name, tag } = splitImageRef(image);
const resp = await dockerFetch(
`/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`,
{ method: "POST" },
);
// Consume body to completion
await resp.body?.cancel();
}
function parseMemoryBytes(s: string): number {
const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmgKMG]?)b?$/);
if (!m) return 0;
const n = parseFloat(m[1] ?? "0");
switch ((m[2] ?? "").toLowerCase()) {
case "k":
return Math.floor(n * 1024);
case "m":
return Math.floor(n * 1024 * 1024);
case "g":
return Math.floor(n * 1024 * 1024 * 1024);
default:
return Math.floor(n);
}
}
async function createContainer(
runId: number,
cfg: CiConfig,
repoAbsPath: string,
envVars: string[],
): Promise<string> {
const binds: string[] = [`${repoAbsPath}:/hearthforge-repo.git:ro`];
if (cfg.cache) {
for (const cachePath of cfg.cache) {
const volName = `hearthforge-ci-cache-${Buffer.from(`${runId}-${cachePath}`).toString("base64url").slice(0, 24)}`;
binds.push(`${volName}:${cachePath}`);
}
}
const hostConfig: Record<string, unknown> = { Binds: binds };
if (cfg.cpu_limit) {
hostConfig.NanoCpus = Math.floor(cfg.cpu_limit * 1e9);
}
if (cfg.memory_limit) {
hostConfig.Memory = parseMemoryBytes(cfg.memory_limit);
}
const body = JSON.stringify({
Image: cfg.image,
Cmd: ["sleep", "infinity"],
Env: envVars,
WorkingDir: cfg.work_dir ?? "/",
HostConfig: hostConfig,
});
const resp = await dockerFetch(
`/containers/create?name=hearthforge-ci-${runId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
},
);
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Failed to create container: ${resp.status} ${text}`);
}
const data = (await resp.json()) as { Id: string };
return data.Id;
}
async function startContainer(containerId: string): Promise<void> {
const resp = await dockerFetch(`/containers/${containerId}/start`, {
method: "POST",
});
if (!resp.ok && resp.status !== 304) {
throw new Error(`Failed to start container: ${resp.status}`);
}
await resp.body?.cancel();
}
interface ExecResult {
log: string;
exitCode: number;
}
function parseMuxStream(data: Uint8Array): string {
const chunks: string[] = [];
const dec = new TextDecoder();
let i = 0;
while (i + 8 <= data.length) {
const view = new DataView(data.buffer, data.byteOffset + i, 8);
const size = view.getUint32(4, false);
i += 8;
if (i + size > data.length) break;
chunks.push(dec.decode(data.slice(i, i + size)));
i += size;
}
return chunks.join("");
}
async function execInContainer(
containerId: string,
cmd: string[],
workDir?: string,
envVars?: string[],
signal?: AbortSignal,
): Promise<ExecResult> {
// Create exec
const execBody = JSON.stringify({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
...(workDir ? { WorkingDir: workDir } : {}),
...(envVars ? { Env: envVars } : {}),
});
const createResp = await dockerFetch(`/containers/${containerId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: execBody,
signal,
});
if (!createResp.ok) {
const text = await createResp.text();
throw new Error(`Failed to create exec: ${createResp.status} ${text}`);
}
const createData = (await createResp.json()) as { Id: string };
const execId = createData.Id;
// Start exec and capture output
const startResp = await dockerFetch(`/exec/${execId}/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Detach: false, Tty: false }),
signal,
});
const bodyBytes = new Uint8Array(await startResp.arrayBuffer());
const log = parseMuxStream(bodyBytes);
// Get exit code
const inspectResp = await dockerFetch(`/exec/${execId}/json`);
const inspectData = (await inspectResp.json()) as { ExitCode: number };
return { log, exitCode: inspectData.ExitCode ?? 1 };
}
async function removeContainer(containerId: string): Promise<void> {
try {
const resp = await dockerFetch(
`/containers/${containerId}?force=true`,
{ method: "DELETE" },
);
await resp.body?.cancel();
} catch {
// Best-effort cleanup
}
}
// --- Tar extraction ---
function extractSingleFileFromTar(data: Uint8Array): Uint8Array | null {
if (data.length < 512) return null;
const dec = new TextDecoder();
const sizeOctal = dec
.decode(data.slice(124, 136))
.replace(/\0/g, "")
.trim();
const size = parseInt(sizeOctal, 8);
if (Number.isNaN(size) || size < 0) return null;
if (data.length < 512 + size) return null;
return data.slice(512, 512 + size);
}
async function copyFileFromContainer(
containerId: string,
containerPath: string,
): Promise<Uint8Array | null> {
const resp = await dockerFetch(
`/containers/${containerId}/archive?path=${encodeURIComponent(containerPath)}`,
);
if (!resp.ok) return null;
const tarBytes = new Uint8Array(await resp.arrayBuffer());
return extractSingleFileFromTar(tarBytes);
}
// --- Secret masking ---
function maskSecrets(text: string, secrets: string[]): string {
for (const secret of secrets) {
if (secret) text = text.split(secret).join("[MASKED]");
}
return text;
}
// --- Env var building ---
function buildEnvVars(
runId: number,
repoName: string,
opts: TriggerOpts,
cfg: CiConfig,
secretValues: Array<{ name: string; value: string }>,
): { envArray: string[]; secretValues: string[] } {
const vars: Record<string, string> = {
CI: "true",
CI_PIPELINE_ID: String(runId),
CI_REPO_NAME: repoName,
CI_SERVER_URL: config.BASE_URL,
CI_TRIGGER_SOURCE: opts.triggerSource,
CI_COMMIT_SHA: opts.commitSha,
CI_COMMIT_SHORT_SHA: opts.commitSha.slice(0, 8),
CI_COMMIT_BRANCH: opts.commitBranch ?? "",
CI_COMMIT_TAG: opts.commitTag ?? "",
CI_COMMIT_REF_NAME: opts.commitTag ?? opts.commitBranch ?? "",
};
// User-defined variable defaults
if (cfg.variables) {
for (const [name, def] of Object.entries(cfg.variables)) {
if (def.default !== undefined) vars[name] = def.default;
}
}
// Variable overrides from manual trigger
if (opts.variableOverrides) {
for (const [name, value] of Object.entries(opts.variableOverrides)) {
vars[name] = value;
}
}
// Secrets (injected but values tracked for masking)
const secretVals: string[] = [];
for (const { name, value } of secretValues) {
vars[name] = value;
secretVals.push(value);
}
const envArray = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
return { envArray, secretValues: secretVals };
}
// --- Artifact collection ---
async function collectArtifacts(
runId: number,
containerId: string,
step: CiStep,
shell: string[],
workDir: string | undefined,
envVars: string[],
): Promise<void> {
const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId));
mkdirSync(artifactDir, { recursive: true });
const toArray = (v: string | string[] | undefined): string[] => {
if (!v) return [];
return Array.isArray(v) ? v : [v];
};
// publish_file: copy directly out of container
for (const srcPath of toArray(step.publish_file)) {
const fileBytes = await copyFileFromContainer(containerId, srcPath);
if (fileBytes) {
const filename = path.basename(srcPath);
const destPath = path.join(artifactDir, filename);
writeFileSync(destPath, fileBytes);
const stat = Bun.file(destPath);
await db
.insertInto("ci_artifacts")
.values({
run_id: runId,
filename,
size: stat.size,
})
.execute();
}
}
type ArchiveType = "tar" | "gzip" | "zip" | "zstd";
const archiveFormats: Array<{
type: ArchiveType;
paths: string[];
ext: string;
cmd: (src: string, dst: string) => string[];
}> = [
{
type: "tar",
paths: toArray(step.publish_tar),
ext: ".tar",
cmd: (src, dst) => [
"tar",
"-cf",
dst,
"-C",
path.dirname(src),
path.basename(src),
],
},
{
type: "gzip",
paths: toArray(step.publish_gzip),
ext: ".tar.gz",
cmd: (src, dst) => [
"tar",
"-czf",
dst,
"-C",
path.dirname(src),
path.basename(src),
],
},
{
type: "zstd",
paths: toArray(step.publish_zstd),
ext: ".tar.zst",
cmd: (src, dst) => [
"tar",
"--zstd",
"-cf",
dst,
"-C",
path.dirname(src),
path.basename(src),
],
},
{
type: "zip",
paths: toArray(step.publish_zip),
ext: ".zip",
cmd: (src, dst) => [
"sh",
"-c",
`cd ${path.dirname(src)} && zip -r ${dst} ${path.basename(src)}`,
],
},
];
let archiveIndex = 0;
for (const { paths: archivePaths, ext, cmd } of archiveFormats) {
for (const srcPath of archivePaths) {
archiveIndex++;
const tmpPath = `/tmp/hf-artifact-${runId}-${archiveIndex}${ext}`;
// Create archive inside container
const execResult = await execInContainer(
containerId,
cmd(srcPath, tmpPath),
workDir,
envVars,
).catch(() => null);
if (!execResult || execResult.exitCode !== 0) continue;
// Copy archive out
const fileBytes = await copyFileFromContainer(containerId, tmpPath);
if (!fileBytes) continue;
const filename = `${path.basename(srcPath)}${ext}`;
const destPath = path.join(artifactDir, filename);
writeFileSync(destPath, fileBytes);
const stat = Bun.file(destPath);
await db
.insertInto("ci_artifacts")
.values({
run_id: runId,
filename,
size: stat.size,
})
.execute();
}
}
}
// --- Main execution ---
async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
const now = () => new Date().toISOString();
let containerId: string | undefined;
try {
// Mark as running
await db
.updateTable("ci_runs")
.set({ status: "running", started_at: now() })
.where("id", "=", runId)
.execute();
// Load run details
const run = await db
.selectFrom("ci_runs")
.selectAll()
.where("id", "=", runId)
.executeTakeFirst();
if (!run) throw new Error("Run not found");
const repo = await db
.selectFrom("repositories")
.select(["id", "name"])
.where("id", "=", run.repo_id)
.executeTakeFirst();
if (!repo) throw new Error("Repo not found");
// Read .hearthforge-ci.toml at the commit
const tomlBuf = await import("./git.ts").then((g) =>
g.git.show(repo.name, run.commit_sha!, ".hearthforge-ci.toml"),
);
if (!tomlBuf)
throw new Error(".hearthforge-ci.toml not found at commit");
const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
if (!cfg) throw new Error("Failed to parse .hearthforge-ci.toml");
// Load secrets for log masking
const secrets = await db
.selectFrom("ci_secrets")
.select(["name", "value"])
.where("repo_id", "=", repo.id)
.execute();
const variableOverrides = run.variable_overrides
? (JSON.parse(run.variable_overrides) as Record<string, string>)
: {};
const { envArray, secretValues } = buildEnvVars(
runId,
repo.name,
{
triggerSource:
run.trigger_source as TriggerOpts["triggerSource"],
commitSha: run.commit_sha ?? "",
commitBranch: run.commit_branch ?? undefined,
commitTag: run.commit_tag ?? undefined,
variableOverrides,
},
cfg,
secrets,
);
// Create step rows in DB
for (const step of cfg.steps) {
await db
.insertInto("ci_steps")
.values({
run_id: runId,
name: step.name,
status: "pending",
})
.execute();
}
// Pull image
await pullImage(cfg.image);
if (signal.aborted) throw new Error("Cancelled");
// Create + start container
containerId = await createContainer(
runId,
cfg,
repoPath(repo.name),
envArray,
);
runningTasks.get(runId)!.containerId = containerId;
await startContainer(containerId);
if (signal.aborted) throw new Error("Cancelled");
// Create work_dir
if (cfg.work_dir) {
await execInContainer(containerId, ["mkdir", "-p", cfg.work_dir]);
}
// Clone project if requested
if (cfg.clone_project_to && run.commit_sha) {
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}`,
],
cfg.work_dir,
envArray,
);
}
// Execute steps
const shell = cfg.shell ?? ["/bin/sh", "-c"];
let runFailed = false;
for (const step of cfg.steps) {
if (signal.aborted) {
runFailed = true;
break;
}
const stepRow = await db
.selectFrom("ci_steps")
.select("id")
.where("run_id", "=", runId)
.where("name", "=", step.name)
.executeTakeFirst();
if (!stepRow) continue;
const stepId = stepRow.id;
// Check run_if condition
if (step.run_if) {
const { exitCode } = await execInContainer(
containerId,
[...shell, step.run_if],
cfg.work_dir,
envArray,
);
if (exitCode !== 0) {
await db
.updateTable("ci_steps")
.set({
status: "skipped",
started_at: now(),
finished_at: now(),
})
.where("id", "=", stepId)
.execute();
continue;
}
}
// Handle clear option
if (step.clear && cfg.clone_project_to && run.commit_sha) {
await execInContainer(
containerId,
[
"sh",
"-c",
`git -C ${cfg.clone_project_to} reset --hard ${run.commit_sha} && git -C ${cfg.clone_project_to} clean -fdx`,
],
cfg.work_dir,
envArray,
);
}
await db
.updateTable("ci_steps")
.set({ status: "running", started_at: now() })
.where("id", "=", stepId)
.execute();
let stepLog = "";
let stepStatus: "success" | "failure" = "success";
if (step.run_sh) {
const command = cfg.shell_setup
? `${cfg.shell_setup}\n${step.run_sh}`
: step.run_sh;
const stepTimeout =
step.timeout ?? cfg.timeout ?? config.CI_DEFAULT_TIMEOUT;
const timeoutSignal = AbortSignal.timeout(stepTimeout * 1000);
try {
const { log, exitCode } = await execInContainer(
containerId,
[...shell, command],
cfg.work_dir,
envArray,
timeoutSignal,
);
stepLog = maskSecrets(log, secretValues);
if (exitCode !== 0) {
stepStatus = "failure";
runFailed = true;
}
} catch (err) {
stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
stepStatus = "failure";
runFailed = true;
}
}
// Collect artifacts for this step
if (!runFailed || stepStatus === "success") {
await collectArtifacts(
runId,
containerId,
step,
shell,
cfg.work_dir,
envArray,
).catch(() => {});
}
await db
.updateTable("ci_steps")
.set({
status: stepStatus,
finished_at: now(),
log: stepLog,
})
.where("id", "=", stepId)
.execute();
if (runFailed) break;
}
// Mark remaining steps as skipped
await db
.updateTable("ci_steps")
.set({ status: "skipped", started_at: now(), finished_at: now() })
.where("run_id", "=", runId)
.where("status", "=", "pending")
.execute();
const finalStatus = runFailed ? "failure" : "success";
await db
.updateTable("ci_runs")
.set({ status: finalStatus, finished_at: now() })
.where("id", "=", runId)
.execute();
} catch (err) {
const status = signal.aborted ? "cancelled" : "failure";
const errMsg = err instanceof Error ? err.message : String(err);
// Write error to a synthetic step if we have no steps yet
const hasSteps = await db
.selectFrom("ci_steps")
.select("id")
.where("run_id", "=", runId)
.executeTakeFirst();
if (!hasSteps) {
await db
.insertInto("ci_steps")
.values({
run_id: runId,
name: "setup",
status: "failure",
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
log: `Error: ${errMsg}\n`,
})
.execute();
}
await db
.updateTable("ci_runs")
.set({ status, finished_at: new Date().toISOString() })
.where("id", "=", runId)
.execute();
// Mark pending steps as skipped
await db
.updateTable("ci_steps")
.set({ status: "skipped", finished_at: new Date().toISOString() })
.where("run_id", "=", runId)
.where("status", "=", "pending")
.execute();
} finally {
if (containerId) await removeContainer(containerId);
runningTasks.delete(runId);
// Prune old history
const run = await db
.selectFrom("ci_runs")
.select("repo_id")
.where("id", "=", runId)
.executeTakeFirst();
if (run) await pruneHistory(run.repo_id).catch(() => {});
}
}
// --- Public API ---
export async function triggerRun(
repoName: string,
opts: TriggerOpts,
): Promise<number> {
const repo = await db
.selectFrom("repositories")
.select("id")
.where("name", "=", repoName)
.executeTakeFirst();
if (!repo) throw new Error("Repository not found");
const runId = await db
.insertInto("ci_runs")
.values({
repo_id: repo.id,
triggered_by: opts.triggeredBy ?? null,
trigger_source: opts.triggerSource,
commit_sha: opts.commitSha,
commit_branch: opts.commitBranch ?? null,
commit_tag: opts.commitTag ?? null,
status: "pending",
variable_overrides: opts.variableOverrides
? JSON.stringify(opts.variableOverrides)
: null,
})
.returning("id")
.executeTakeFirstOrThrow();
const controller = new AbortController();
runningTasks.set(runId.id, { controller });
// Fire and forget — like release archiving
(async () => {
await executeRun(runId.id, controller.signal);
})();
return runId.id;
}
export async function cancelRun(runId: number): Promise<void> {
const task = runningTasks.get(runId);
if (task) {
const { containerId } = task;
task.controller.abort();
if (containerId) {
await removeContainer(containerId).catch(() => {});
}
}
await db
.updateTable("ci_runs")
.set({ status: "cancelled", finished_at: new Date().toISOString() })
.where("id", "=", runId)
.where("status", "in", ["pending", "running"])
.execute();
}
async function pruneHistory(repoId: number): Promise<void> {
const maxHistory = config.CI_MAX_HISTORY;
const allRuns = await db
.selectFrom("ci_runs")
.select("id")
.where("repo_id", "=", repoId)
.orderBy("id", "desc")
.execute();
if (allRuns.length <= maxHistory) return;
const toDelete = allRuns.slice(maxHistory).map((r) => r.id);
for (const runId of toDelete) {
// Remove artifacts from disk
const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId));
if (existsSync(artifactDir)) {
await Bun.$`rm -rf ${artifactDir}`.quiet().nothrow();
}
}
await db.deleteFrom("ci_runs").where("id", "in", toDelete).execute();
}
/** Reset the cached socket path (used in tests to switch mock sockets). */
export function resetDockerSocket(): void {
resolvedSocket = null;
}
/** Check if CI can connect to the container socket. */
export async function ciAvailable(): Promise<boolean> {
try {
const socket = await getSocket();
const resp = await fetch("http://localhost/v1.47/info", {
unix: socket,
});
return resp.ok;
} catch {
return false;
}
}
▾Msrc/services/markdown.ts
@@ -1,6 +1,10 @@
import DOMPurify from "isomorphic-dompurify";
import { Marked, marked, type Tokens } from "marked";
import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD } from "../constants.ts";
import {
MAX_MD_CACHE,
PREVIEW_MAX_LENGTH,
PREVIEW_TRUNCATION_THRESHOLD,
} from "../constants.ts";
marked.setOptions({ gfm: true });
@@ -181,14 +185,18 @@ export function markdownToPlaintext(md: string): string {
* Returns a short single-line preview of a plaintext string:
* the first paragraph/heading line, truncated to maxLen chars.
*/
export function plaintextPreview(text: string, maxLen = PREVIEW_MAX_LENGTH): string {
export function plaintextPreview(
text: string,
maxLen = PREVIEW_MAX_LENGTH,
): string {
const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
const firstLine = firstBlock.split("\n")[0] ?? "";
if (firstLine.length <= maxLen) return firstLine;
const truncated = firstLine.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(" ");
return (
(lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated) +
"…"
(lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
? truncated.slice(0, lastSpace)
: truncated) + "…"
);
}
▾Msrc/styles/components.css
@@ -1927,4 +1927,216 @@
align-items: center;
gap: var(--space-2);
}
/* --- CI / Pipelines --- */
.ci-status-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ci-status-pending {
background: var(--color-border);
color: var(--color-text-muted);
}
.ci-status-running {
background: #0b5cab;
color: #fff;
}
.ci-status-success {
background: #1a7f37;
color: #fff;
}
.ci-status-failure {
background: #cf222e;
color: #fff;
}
.ci-status-cancelled {
background: var(--color-border);
color: var(--color-text-muted);
}
.ci-status-skipped {
background: var(--color-border);
color: var(--color-text-muted);
}
.ci-run-id {
font-weight: 600;
margin-left: var(--space-2);
}
.ci-sha {
font-family: var(--font-mono);
font-size: var(--text-xs);
background: var(--color-code-bg);
padding: 1px 4px;
border-radius: 3px;
}
.ci-run-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.ci-steps {
margin-top: var(--space-4);
}
.ci-step {
border: 1px solid var(--color-border);
border-radius: 6px;
margin-bottom: var(--space-2);
overflow: hidden;
}
.ci-step-summary {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
cursor: pointer;
list-style: none;
background: var(--color-bg-secondary);
}
.ci-step-summary::-webkit-details-marker {
display: none;
}
.ci-step-name {
font-weight: 600;
flex: 1;
}
.ci-step-duration {
font-size: var(--text-xs);
}
.ci-step-log {
margin: 0;
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-xs);
background: var(--color-code-bg);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 500px;
overflow-y: auto;
}
.ci-step-running-indicator {
padding: var(--space-3);
font-size: var(--text-sm);
}
.ci-step-pending {
padding: var(--space-3);
color: var(--color-text-muted);
}
.ci-artifacts {
margin-top: var(--space-4);
}
.ci-artifact-list {
list-style: none;
margin: 0;
padding: 0;
}
.ci-artifact-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border);
}
.ci-artifact-item:last-child {
border-bottom: none;
}
.ci-artifact-name {
font-weight: 500;
}
.ci-artifact-size {
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.ci-overrides {
margin-top: var(--space-4);
}
.ci-vars-list {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin: 0;
}
.ci-vars-list dt {
color: var(--color-text-muted);
}
.ci-vars-list dd {
margin: 0;
}
.ci-help {
margin-top: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.ci-help-summary {
cursor: pointer;
font-weight: 600;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-2);
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.ci-help-summary::-webkit-details-marker {
display: none;
}
.ci-help-download {
flex-shrink: 0;
}
.ci-help-body {
padding: var(--space-2);
border-top: 1px solid var(--color-border);
margin-top: 0.5rem;
}
.ci-help-desc {
margin: var(--space-1) 0 var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ci-help-sections {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.ci-help-section-title {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin: 0 0 var(--space-2);
}
.ci-help-vars {
display: grid;
grid-template-columns: max-content 1fr;
gap: 2px var(--space-3);
margin: 0;
font-size: var(--text-xs);
}
.ci-help-vars dt { margin: 0; }
.ci-help-vars dd {
margin: 0;
color: var(--color-text-muted);
align-self: center;
}
.ci-help-badge-desc {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0 0 var(--space-1);
}
.ci-help-badge-code {
display: block;
font-size: var(--text-xs);
background: var(--color-code-bg);
padding: var(--space-1) var(--space-2);
border-radius: 3px;
overflow-x: auto;
white-space: nowrap;
user-select: all;
}
}
▾Asrc/views/ci/CiHistory.tsx
@@ -0,0 +1,243 @@
import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { Pagination, type PaginationInfo } from "../Pagination.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
import { CiStatusPill } from "./CiStatusPill.tsx";
interface RunSummary {
id: number;
status: string;
trigger_source: string;
commit_sha: string | null;
commit_branch: string | null;
commit_tag: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
triggered_by_username: string | null;
artifact_count: number;
}
interface CiHistoryProps {
user: SessionUser | null;
repo: RepositoryRow;
runs: RunSummary[];
pagination: PaginationInfo;
manualTriggerDisabledReason: string | null;
}
function duration(start: string | null, end: string | null): string {
if (!start || !end) return "";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (ms < 0) return "";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
}
const CI_VARIABLES = [
["CI", "always true"],
["CI_PIPELINE_ID", "numeric run ID"],
["CI_COMMIT_SHA", "full commit hash"],
["CI_COMMIT_SHORT_SHA", "first 8 chars"],
["CI_COMMIT_BRANCH", "branch name (empty for tags)"],
["CI_COMMIT_TAG", "tag name (empty for branches)"],
["CI_COMMIT_REF_NAME", "branch or tag name"],
["CI_TRIGGER_SOURCE", "push · tag · manual"],
["CI_REPO_NAME", "repository name"],
["CI_SERVER_URL", "HearthForge base URL"],
];
function CiHelp({ repo }: { repo: RepositoryRow }) {
return (
<details class="form-card ci-help">
<summary class="ci-help-summary">
<span>How to define a pipeline file</span>
<a
href="/assets/hearthforge-ci-template.toml"
download=".hearthforge-ci.toml"
class="btn btn-sm btn-secondary ci-help-download"
>
Download template
</a>
</summary>
<div class="ci-help-body">
<p class="ci-help-desc">
Add <code>.hearthforge-ci.toml</code> to your repository
root. Each <code>[section]</code> is a step executed in
file order. Reserved tables:{" "}
<code>[on]</code> (triggers) and{" "}
<code>[variables]</code> (user-overridable inputs).
</p>
<div class="ci-help-sections">
<div class="ci-help-section">
<h4 class="ci-help-section-title">
Predefined variables
</h4>
<dl class="ci-help-vars">
{CI_VARIABLES.map(([name, desc]) => (
<>
<dt>
<code>{name}</code>
</dt>
<dd>{desc}</dd>
</>
))}
</dl>
</div>
<div class="ci-help-section">
<h4 class="ci-help-section-title">Status badge</h4>
<p class="ci-help-badge-desc">
Embed in your README:
</p>
<code class="ci-help-badge-code">{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
<h4 class="ci-help-section-title" style="margin-top: var(--space-4)">
Artifact types
</h4>
<dl class="ci-help-vars">
{[
["publish_file", "copy file as-is"],
["publish_tar", ".tar archive"],
["publish_gzip", ".tar.gz archive"],
["publish_zstd", ".tar.zst archive"],
["publish_zip", ".zip archive"],
].map(([k, v]) => (
<>
<dt>
<code>{k}</code>
</dt>
<dd>{v}</dd>
</>
))}
</dl>
</div>
</div>
</div>
</details>
);
}
export function CiHistory({ user, repo, runs, pagination, manualTriggerDisabledReason }: CiHistoryProps) {
const isRunning = runs.some(
(r) => r.status === "pending" || r.status === "running",
);
return (
<Layout user={user} title={`Pipelines — ${repo.name}`}>
{
(isRunning ? (
<meta http-equiv="refresh" content="4" />
) : (
""
)) as unknown as JSX.Element
}
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
<div class="list-header">
<h2 class="list-heading">Pipelines</h2>
{user?.isAdmin && (
<form
method="POST"
action={`/${repo.name}/ci/run`}
class="inline-form"
>
<button
type="submit"
class="btn btn-primary btn-sm"
disabled={manualTriggerDisabledReason ? true : undefined}
title={manualTriggerDisabledReason ?? undefined}
>
Run pipeline
</button>
</form>
)}
</div>
{runs.length === 0 ? (
<div class="empty-state">
<p>No pipeline runs yet.</p>
<p class="text-muted">
Push a <code>.hearthforge-ci.toml</code> to your
repository to get started.
</p>
</div>
) : (
<ul class="issue-list">
{runs.map((run) => (
<li class="issue-item">
<div class="release-item-header">
<div class="release-item-main">
<a
href={`/${repo.name}/ci/${run.id}`}
class="release-item-title"
>
<CiStatusPill status={run.status} />
<span class="ci-run-id">
#{run.id}
</span>
</a>
<div class="release-item-meta">
{run.commit_sha && (
<code class="ci-sha">
{run.commit_sha.slice(0, 8)}
</code>
)}
{run.commit_branch && (
<span class="badge">
{run.commit_branch}
</span>
)}
{run.commit_tag && (
<span class="badge">
{run.commit_tag}
</span>
)}
<span class="text-muted">
{run.trigger_source}
</span>
{run.triggered_by_username && (
<span class="text-muted">
by{" "}
{run.triggered_by_username}
</span>
)}
{run.artifact_count > 0 && (
<span>
{run.artifact_count}{" "}
artifact
{run.artifact_count !== 1
? "s"
: ""}
</span>
)}
{run.started_at &&
run.finished_at && (
<span class="text-muted">
{duration(
run.started_at,
run.finished_at,
)}
</span>
)}
</div>
</div>
<div class="release-item-date">
<time datetime={run.created_at}>
{formatDateTime(run.created_at)}
</time>
</div>
</div>
</li>
))}
</ul>
)}
<Pagination {...pagination} />
<CiHelp repo={repo} />
</div>
</Layout>
);
}
▾Asrc/views/ci/CiRunDetail.tsx
@@ -0,0 +1,234 @@
import type {
CiArtifactRow,
CiStepRow,
RepositoryRow,
} from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
import { CiStatusPill } from "./CiStatusPill.tsx";
interface RunDetail {
id: number;
status: string;
trigger_source: string;
commit_sha: string | null;
commit_branch: string | null;
commit_tag: string | null;
variable_overrides: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
triggered_by_username: string | null;
}
interface CiRunDetailProps {
user: SessionUser | null;
repo: RepositoryRow;
run: RunDetail;
steps: CiStepRow[];
artifacts: CiArtifactRow[];
}
function duration(start: string | null, end: string | null): string {
if (!start || !end) return "";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (ms < 0) return "";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function CiRunDetail({
user,
repo,
run,
steps,
artifacts,
}: CiRunDetailProps) {
const isActive = run.status === "pending" || run.status === "running";
const variableOverrides: Record<string, string> = run.variable_overrides
? JSON.parse(run.variable_overrides)
: {};
const hasOverrides = Object.keys(variableOverrides).length > 0;
return (
<Layout user={user} title={`Pipeline #${run.id} — ${repo.name}`}>
{
(isActive ? (
<meta http-equiv="refresh" content="3" />
) : (
""
)) as unknown as JSX.Element
}
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
<div class="release-detail-header">
<div>
<h2 class="release-detail-title">
<CiStatusPill status={run.status} /> Pipeline #
{run.id}
</h2>
<div class="release-item-meta">
{run.commit_sha && (
<code class="ci-sha">
{run.commit_sha.slice(0, 8)}
</code>
)}
{run.commit_branch && (
<a
href={`/${repo.name}/tree/${run.commit_branch}`}
class="badge"
>
{run.commit_branch}
</a>
)}
{run.commit_tag && (
<a
href={`/${repo.name}/tree/${run.commit_tag}`}
class="badge"
>
{run.commit_tag}
</a>
)}
<span class="text-muted">
triggered by {run.trigger_source}
{run.triggered_by_username &&
` (${run.triggered_by_username})`}
</span>
{run.started_at && run.finished_at && (
<span class="text-muted">
{duration(run.started_at, run.finished_at)}
</span>
)}
<time datetime={run.created_at} class="text-muted">
{formatDateTime(run.created_at)}
</time>
</div>
</div>
{user?.isAdmin && (
<div class="ci-run-actions">
{isActive ? (
<form
method="POST"
action={`/${repo.name}/ci/${run.id}/cancel`}
class="inline-form"
>
<button
type="submit"
class="btn btn-danger btn-sm"
>
Cancel
</button>
</form>
) : (
<form
method="POST"
action={`/${repo.name}/ci/${run.id}/retry`}
class="inline-form"
>
<button
type="submit"
class="btn btn-secondary btn-sm"
>
Retry
</button>
</form>
)}
</div>
)}
</div>
{hasOverrides && (
<div class="form-card ci-overrides">
<h3 class="section-title">Variable overrides</h3>
<dl class="ci-vars-list">
{Object.entries(variableOverrides).map(([k, v]) => (
<>
<dt>
<code>{k}</code>
</dt>
<dd>{v}</dd>
</>
))}
</dl>
</div>
)}
<div class="ci-steps">
<h3 class="section-title">Steps</h3>
{steps.length === 0 ? (
<div class="ci-step-pending">
<span class="text-muted">Waiting to start…</span>
</div>
) : (
steps.map((step) => (
<details
class={`ci-step ci-step-${step.status}`}
open={
step.status === "failure" ? true : undefined
}
>
<summary class="ci-step-summary">
<CiStatusPill status={step.status} />
<span class="ci-step-name">
{step.name}
</span>
{step.started_at && step.finished_at && (
<span class="ci-step-duration text-muted">
{duration(
step.started_at,
step.finished_at,
)}
</span>
)}
</summary>
{step.log ? (
<pre class="ci-step-log">{step.log}</pre>
) : step.status === "running" ? (
<div class="ci-step-running-indicator text-muted">
Running…
</div>
) : null}
</details>
))
)}
</div>
{artifacts.length > 0 && (
<div class="form-card ci-artifacts">
<h3 class="section-title">Artifacts</h3>
<ul class="ci-artifact-list">
{artifacts.map((artifact) => (
<li class="ci-artifact-item">
<a
href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
class="ci-artifact-name"
>
{artifact.filename}
</a>
<span class="ci-artifact-size text-muted">
{formatBytes(artifact.size)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
</Layout>
);
}
▾Asrc/views/ci/CiStatusPill.tsx
@@ -0,0 +1,13 @@
const statusStyles: Record<string, string> = {
pending: "ci-status-pending",
running: "ci-status-running",
success: "ci-status-success",
failure: "ci-status-failure",
cancelled: "ci-status-cancelled",
skipped: "ci-status-skipped",
};
export function CiStatusPill({ status }: { status: string }) {
const cls = statusStyles[status] ?? "ci-status-pending";
return <span class={`ci-status-pill ${cls}`}>{status}</span>;
}
▾Msrc/views/repos/BranchList.tsx
@@ -1,8 +1,8 @@
import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts";
import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { BranchInfo } from "../../services/git.ts";
import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
@@ -154,7 +154,9 @@ export function BranchList({
required
placeholder="new-name"
value={b.name}
maxlength={MAX_BRANCH_NAME_LENGTH}
maxlength={
MAX_BRANCH_NAME_LENGTH
}
/>
<button
type="submit"
▾Msrc/views/repos/FileEdit.tsx
@@ -1,6 +1,6 @@
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
▾Msrc/views/repos/NewFileForm.tsx
@@ -1,6 +1,6 @@
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
▾Msrc/views/repos/RepoNav.tsx
@@ -11,6 +11,7 @@ interface RepoNavProps {
| "issues"
| "patches"
| "releases"
| "ci"
| "settings";
user?: SessionUser | null;
}
@@ -28,6 +29,7 @@ export function RepoNav({ repo, active, user }: RepoNavProps) {
{ key: "issues", label: "Issues", href: `/${repo.name}/issues` },
{ key: "patches", label: "Patches", href: `/${repo.name}/patches` },
{ key: "releases", label: "Releases", href: `/${repo.name}/releases` },
{ key: "ci", label: "Pipelines", href: `/${repo.name}/ci` },
] as const;
return (
<div class="repo-nav-bar">
▾Msrc/views/repos/RepoSettings.tsx
@@ -1,4 +1,12 @@
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
interface SecretSummary {
id: number;
name: string;
description: string | null;
created_at: string;
}
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
@@ -9,6 +17,7 @@ interface RepoSettingsProps {
repo: RepositoryRow;
branches: string[];
labels: LabelRow[];
secrets: SecretSummary[];
success?: string;
error?: string;
}
@@ -18,6 +27,7 @@ export function RepoSettings({
repo,
branches,
labels,
secrets,
success,
error,
}: RepoSettingsProps) {
@@ -205,6 +215,81 @@ export function RepoSettings({
</button>
</form>
</div>
<div class="form-card">
<h2 class="section-title">CI Secrets</h2>
<p
class="text-muted"
style="font-size: var(--text-sm); margin-bottom: var(--space-3)"
>
Secrets are injected as environment variables into
pipeline runs and masked in logs. Values are write-only
— they cannot be retrieved after saving.
</p>
{secrets.length > 0 && (
<div class="label-settings-list">
{secrets.map((secret) => (
<div class="label-settings-item">
<span class="label-settings-name">
<code>{secret.name}</code>
</span>
{secret.description && (
<span class="text-muted">
{secret.description}
</span>
)}
<span class="text-muted">●●●●●●</span>
<form
method="POST"
action={`/${repo.name}/settings/ci-secrets/delete`}
>
<input
type="hidden"
name="id"
value={String(secret.id)}
/>
<button
class="btn btn-danger btn-sm"
type="submit"
>
Delete
</button>
</form>
</div>
))}
</div>
)}
<form
method="POST"
action={`/${repo.name}/settings/ci-secrets`}
class="label-add-form"
>
<input
class="form-input label-name-input"
type="text"
name="name"
placeholder="SECRET_NAME"
pattern="[A-Za-z_][A-Za-z0-9_]*"
required
/>
<input
class="form-input label-name-input"
type="password"
name="value"
placeholder="Value"
autocomplete="new-password"
required
/>
<input
class="form-input label-name-input"
type="text"
name="description"
placeholder="Description (optional)"
/>
<button type="submit" class="btn btn-secondary btn-sm">
Save secret
</button>
</form>
</div>
<div class="danger-zone">
<h2 class="section-title danger-title">Danger zone</h2>
<div class="form-card danger-card">
▾Msrc/views/repos/TagList.tsx
@@ -1,8 +1,11 @@
import {
MAX_TAG_MESSAGE_LENGTH,
MAX_TAG_NAME_LENGTH,
} from "../../constants.ts";
import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TagInfo } from "../../services/git.ts";
import { MAX_TAG_MESSAGE_LENGTH, MAX_TAG_NAME_LENGTH } from "../../constants.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
▾Atests/ci.unit.test.ts
@@ -0,0 +1,191 @@
import { describe, test, expect } from "bun:test";
import {
parseCiConfig,
shouldTriggerPush,
shouldTriggerTag,
} from "../src/services/ci.ts";
describe("parseCiConfig", () => {
test("parses a minimal valid config", () => {
const cfg = parseCiConfig(`
image = "debian:latest"
[build]
run_sh = "make all"
`);
expect(cfg).not.toBeNull();
expect(cfg!.image).toBe("debian:latest");
expect(cfg!.steps).toHaveLength(1);
expect(cfg!.steps[0]!.name).toBe("build");
expect(cfg!.steps[0]!.run_sh).toBe("make all");
});
test("returns null when image is missing", () => {
expect(parseCiConfig(`[build]\nrun_sh = "make"`)).toBeNull();
});
test("returns null on invalid TOML", () => {
expect(parseCiConfig("image = [unclosed")).toBeNull();
});
test("excludes reserved table names from steps", () => {
const cfg = parseCiConfig(`
image = "alpine"
[on]
push = ["main"]
[variables]
[variables.FOO]
default = "bar"
[step1]
run_sh = "echo hi"
`);
expect(cfg).not.toBeNull();
expect(cfg!.steps.map((s) => s.name)).toEqual(["step1"]);
});
test("preserves step order", () => {
const cfg = parseCiConfig(`
image = "alpine"
[setup]
run_sh = "apt install"
[compile]
run_sh = "make"
[test]
run_sh = "./test.sh"
`);
expect(cfg!.steps.map((s) => s.name)).toEqual([
"setup",
"compile",
"test",
]);
});
test("parses optional top-level fields", () => {
const cfg = parseCiConfig(`
image = "alpine"
work_dir = "/ci"
clone_project_to = "/ci/repo"
shell_setup = "set -e"
timeout = 1800
cpu_limit = 2.0
memory_limit = "1g"
cache = ["/root/.npm"]
`);
expect(cfg!.work_dir).toBe("/ci");
expect(cfg!.clone_project_to).toBe("/ci/repo");
expect(cfg!.shell_setup).toBe("set -e");
expect(cfg!.timeout).toBe(1800);
expect(cfg!.cpu_limit).toBe(2.0);
expect(cfg!.memory_limit).toBe("1g");
expect(cfg!.cache).toEqual(["/root/.npm"]);
});
test("parses on.push as array", () => {
const cfg = parseCiConfig(`
image = "alpine"
[on]
push = ["main", "develop"]
`);
expect(cfg!.on?.push).toEqual(["main", "develop"]);
});
test("parses on.tag and on.manual", () => {
const cfg = parseCiConfig(`
image = "alpine"
[on]
tag = true
manual = true
`);
expect(cfg!.on?.tag).toBe(true);
expect(cfg!.on?.manual).toBe(true);
});
test("parses variables with default and description", () => {
const cfg = parseCiConfig(`
image = "alpine"
[variables]
[variables.DEPLOY_ENV]
default = "staging"
description = "Target environment"
[variables.VERSION]
default = "1.0.0"
`);
expect(cfg!.variables?.DEPLOY_ENV).toEqual({
default: "staging",
description: "Target environment",
});
expect(cfg!.variables?.VERSION?.default).toBe("1.0.0");
});
test("parses step-level fields", () => {
const cfg = parseCiConfig(`
image = "alpine"
[test]
run_sh = "./run_tests"
run_if = 'test -n "\${CI_COMMIT_TAG}"'
clear = true
timeout = 300
publish_file = ["/dist/binary"]
publish_gzip = ["/dist/"]
`);
const step = cfg!.steps[0]!;
expect(step.run_sh).toBe("./run_tests");
expect(step.run_if).toBe('test -n "${CI_COMMIT_TAG}"');
expect(step.clear).toBe(true);
expect(step.timeout).toBe(300);
expect(step.publish_file).toEqual(["/dist/binary"]);
expect(step.publish_gzip).toEqual(["/dist/"]);
});
});
describe("shouldTriggerPush", () => {
function cfg(push: string[] | boolean) {
return parseCiConfig(
`image="alpine"\n[on]\npush=${JSON.stringify(push)}`,
)!;
}
test("matches exact branch name", () => {
expect(shouldTriggerPush(cfg(["main"]), "main")).toBe(true);
expect(shouldTriggerPush(cfg(["main"]), "develop")).toBe(false);
});
test("wildcard * matches any branch", () => {
expect(shouldTriggerPush(cfg(["*"]), "anything")).toBe(true);
expect(shouldTriggerPush(cfg(["*"]), "main")).toBe(true);
});
test("matches one of multiple branches", () => {
const c = cfg(["main", "release"]);
expect(shouldTriggerPush(c, "main")).toBe(true);
expect(shouldTriggerPush(c, "release")).toBe(true);
expect(shouldTriggerPush(c, "feature/x")).toBe(false);
});
test("returns false when on.push is absent", () => {
const c = parseCiConfig('image="alpine"')!;
expect(shouldTriggerPush(c, "main")).toBe(false);
});
test("glob prefix matching", () => {
const c = cfg(["release/*"]);
expect(shouldTriggerPush(c, "release/1.0")).toBe(true);
expect(shouldTriggerPush(c, "main")).toBe(false);
});
});
describe("shouldTriggerTag", () => {
test("returns true when on.tag = true", () => {
const c = parseCiConfig('image="alpine"\n[on]\ntag=true')!;
expect(shouldTriggerTag(c)).toBe(true);
});
test("returns false when on.tag = false", () => {
const c = parseCiConfig('image="alpine"\n[on]\ntag=false')!;
expect(shouldTriggerTag(c)).toBe(false);
});
test("returns false when on.tag is absent", () => {
const c = parseCiConfig('image="alpine"')!;
expect(shouldTriggerTag(c)).toBe(false);
});
});
▾Atests/e2e.ci.test.ts
@@ -0,0 +1,672 @@
/**
* CI pipeline E2E tests.
*
* Uses a mock Docker API server (Bun.serve over a Unix socket) so no real
* Docker/Podman installation is required. The mock handles every endpoint
* the CI service calls and lets individual tests queue custom exec responses
* (output + exit code) to simulate success, failure, and specific log output.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import {
BASE,
ADMIN_PASS,
DATA_DIR,
setupTestEnv,
spawnServer,
killServer,
seedRepo,
login,
} from "./helpers.ts";
import { db } from "../src/db/index.ts";
import config from "../src/config.ts";
import {
resetDockerSocket,
triggerRun,
} from "../src/services/ci.ts";
import { paths } from "../src/constants.ts";
// ── Mock Docker server ────────────────────────────────────────────────────────
const SOCKET_PATH = `/tmp/test-docker-ci-${process.pid}.sock`;
interface ExecResp {
output: string;
exitCode: number;
}
// 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
// specific step responses
const execQueue: ExecResp[] = [];
let execCounter = 0;
function queueExec(resp: ExecResp) {
execQueue.push(resp);
}
function resetMock() {
execMap.clear();
execQueue.length = 0;
execCounter = 0;
}
/** Build a Docker multiplexed stream frame from a string. */
function muxFrame(text: string, stream = 1): Uint8Array {
const payload = Buffer.from(text, "utf-8");
const hdr = Buffer.alloc(8);
hdr[0] = stream;
hdr.writeUInt32BE(payload.length, 4);
return Buffer.concat([hdr, payload]);
}
/** Build a minimal tar archive containing one file. */
function makeTar(filename: string, content: string): Uint8Array {
const data = Buffer.from(content, "utf-8");
const hdr = Buffer.alloc(512);
hdr.write(path.basename(filename).slice(0, 100), 0, "ascii");
hdr.write("0000644\0", 100, "ascii"); // mode
hdr.write("0000000\0", 108, "ascii"); // uid
hdr.write("0000000\0", 116, "ascii"); // gid
hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
hdr.write("00000000000\0", 136, "ascii"); // mtime
hdr[156] = 0x30; // type flag: regular file
// Checksum: fill with spaces, compute, write back
hdr.fill(0x20, 148, 156);
let sum = 0;
for (let i = 0; i < 512; i++) sum += hdr[i]!;
hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
// Pad file content to 512-byte block
const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512;
const padded = Buffer.alloc(paddedLen);
data.copy(padded);
return Buffer.concat([hdr, padded]);
}
let mockServer: ReturnType<typeof Bun.serve>;
function startMockDocker() {
rmSync(SOCKET_PATH, { force: true });
mockServer = Bun.serve({
unix: SOCKET_PATH,
fetch(req: Request): Response {
const p = new URL(req.url).pathname;
const qs = new URL(req.url).searchParams;
// Health check
if (req.method === "GET" && p === "/v1.47/info") {
return Response.json({ ServerVersion: "mock" });
}
// Pull image (streaming, just needs to resolve)
if (req.method === "POST" && p.startsWith("/v1.47/images/create")) {
return new Response('{"status":"Pull complete"}\n');
}
// Create container
if (req.method === "POST" && /\/containers\/create/.test(p)) {
return Response.json({ Id: "mock-ctr-001" });
}
// Start container
if (
req.method === "POST" &&
/\/containers\/[^/]+\/start$/.test(p)
) {
return new Response(null, { status: 204 });
}
// Create exec — pop next queued response and assign to this exec ID
if (
req.method === "POST" &&
/\/containers\/[^/]+\/exec$/.test(p)
) {
execCounter++;
const execId = `mock-exec-${execCounter}`;
execMap.set(
execId,
execQueue.shift() ?? { output: "", exitCode: 0 },
);
return Response.json({ Id: execId });
}
// Start exec — return queued output as mux stream
if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) {
const id = p.match(/\/exec\/([^/]+)\/start/)![1]!;
const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
return new Response(
resp.output ? muxFrame(resp.output) : new Uint8Array(0),
);
}
// Inspect exec — return exit code
if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) {
const id = p.match(/\/exec\/([^/]+)\/json/)![1]!;
const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
return Response.json({ ExitCode: resp.exitCode });
}
// Archive (used by publish_file artifact collection)
if (
req.method === "GET" &&
/\/containers\/[^/]+\/archive/.test(p)
) {
const filePath = qs.get("path") ?? "file.txt";
return new Response(
makeTar(path.basename(filePath), "artifact-content-123"),
{ headers: { "Content-Type": "application/x-tar" } },
);
}
// Delete container
if (req.method === "DELETE" && /\/containers\//.test(p)) {
return new Response(null, { status: 204 });
}
return new Response("Not found", { status: 404 });
},
});
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */
function seedCiToml(repoName: string, toml: string): string {
const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`);
const tmp = `/tmp/hf-ci-seed-${Date.now()}`;
try {
spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" });
spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], {
stdio: "ignore",
});
writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml);
spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], {
stdio: "ignore",
});
spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], {
stdio: "ignore",
});
const r = spawnSync(
"git",
["-C", tmp, "rev-parse", "HEAD"],
{ stdio: ["ignore", "pipe", "ignore"] },
);
return r.stdout.toString().trim();
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/** Poll until a CI run leaves pending/running state, then return its status. */
async function waitForRun(runId: number, timeoutMs = 10_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const row = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
if (row && row.status !== "pending" && row.status !== "running") {
return row.status;
}
await Bun.sleep(100);
}
throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
}
async function loggedInContext(
browser: Browser,
username = "admin",
password = ADMIN_PASS,
): Promise<BrowserContext> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// ── Test setup ────────────────────────────────────────────────────────────────
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
let adminCtx: BrowserContext;
let adminUserId: number;
let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml
const SIMPLE_TOML = `
image = "debian:latest"
[on]
manual = true
push = ["main"]
[hello]
run_sh = "echo hello"
`;
const ARTIFACT_TOML = `
image = "debian:latest"
work_dir = "/ci"
[on]
manual = true
[build]
run_sh = "echo building"
publish_file = ["/ci/output.txt"]
`;
beforeAll(async () => {
await setupTestEnv();
// Point CI service at mock socket BEFORE starting any runs
config.CI_DOCKER_SOCKET = SOCKET_PATH;
resetDockerSocket();
startMockDocker();
server = await spawnServer();
browser = await chromium.launch();
adminCtx = await loggedInContext(browser);
// Get admin user ID
const row = await db
.selectFrom("users")
.select("id")
.where("username", "=", "admin")
.executeTakeFirst();
adminUserId = row!.id;
// Create ci-repo via UI and seed it
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill("[name=name]", "ci-repo");
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/ci-repo`);
} finally {
await page.close();
}
seedRepo("ci-repo");
ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
});
afterAll(async () => {
await adminCtx.close();
await browser.close();
await killServer(server);
mockServer.stop(true);
rmSync(SOCKET_PATH, { force: true });
});
beforeEach(() => {
resetMock();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("pipelines tab", () => {
test("tab is visible in repo nav", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo`);
const tab = page.locator('.repo-tab', { hasText: 'Pipelines' });
expect(await tab.isVisible()).toBe(true);
} finally {
await page.close();
}
});
test("history page shows empty state when no runs", async () => {
// Use a separate repo that has never had a run
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
expect(await page.locator(".empty-state").isVisible()).toBe(true);
expect(await page.locator(".empty-state").textContent()).toContain(
"No pipeline runs yet",
);
} finally {
await page.close();
}
});
test("help section is collapsible and contains template download", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const help = page.locator("details.ci-help");
expect(await help.isVisible()).toBe(true);
await help.locator("summary").click();
const dlLink = page.locator('a[download=".hearthforge-ci.toml"]');
expect(await dlLink.isVisible()).toBe(true);
} finally {
await page.close();
}
});
});
describe("successful run", () => {
let runId: number;
beforeAll(async () => {
queueExec({ output: "hello from mock CI\n", exitCode: 0 });
runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: ciRepoSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
await waitForRun(runId);
});
test("run status is success", async () => {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("success");
});
test("step status is success and log is captured", async () => {
const step = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("success");
expect(step?.log).toContain("hello from mock CI");
});
test("history page shows the completed run", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
expect(
await page.locator(".ci-status-pill.ci-status-success").count(),
).toBeGreaterThan(0);
} finally {
await page.close();
}
});
test("run detail page shows step and log", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-step").first().textContent(),
).toContain("hello");
// Open step details to see log
await page.locator(".ci-step").first().click();
expect(await page.locator(".ci-step-log").textContent()).toContain(
"hello from mock CI",
);
} finally {
await page.close();
}
});
test("retry creates a new run", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
await page.click('button:text("Retry")');
// Should redirect to the new run
await page.waitForURL(/\/ci-repo\/ci\/\d+/);
const newRunId = Number(
page.url().split("/ci/")[1],
);
expect(newRunId).toBeGreaterThan(runId);
// Wait for new run to complete (uses default exit 0)
const status = await waitForRun(newRunId);
expect(status).toBe("success");
} finally {
await page.close();
}
});
});
describe("failing run", () => {
let runId: number;
beforeAll(async () => {
// Step exec: non-zero exit code
queueExec({ output: "build error: file not found\n", exitCode: 1 });
runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: ciRepoSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
await waitForRun(runId);
});
test("run status is failure", async () => {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("failure");
});
test("step status is failure and error log captured", async () => {
const step = await db
.selectFrom("ci_steps")
.select(["status", "log"])
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.status).toBe("failure");
expect(step?.log).toContain("build error");
});
test("run detail page shows failure status", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-status-pill.ci-status-failure").count(),
).toBeGreaterThan(0);
} finally {
await page.close();
}
});
});
describe("cancel", () => {
test("cancelling a pending run marks it cancelled", async () => {
// Trigger without queuing — run will start and eventually succeed,
// but we cancel immediately before it gets far
const runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: ciRepoSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
// Cancel via API before it completes
const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, {
method: "POST",
redirect: "manual",
});
expect(resp.status).toBe(302);
// Wait and check final status
const status = await waitForRun(runId);
expect(["cancelled", "success", "failure"]).toContain(status);
// If we got there first, it's cancelled
if (status === "cancelled") {
const run = await db
.selectFrom("ci_runs")
.select("status")
.where("id", "=", runId)
.executeTakeFirst();
expect(run?.status).toBe("cancelled");
}
});
});
describe("artifacts", () => {
let runId: number;
let artifactId: number;
beforeAll(async () => {
// Seed repo with artifact TOML
const sha = seedCiToml("ci-repo", ARTIFACT_TOML);
// work_dir causes 1 mkdir exec before the step
// defaults: {output:'', exitCode:0} for both
runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: sha,
commitBranch: "main",
triggeredBy: adminUserId,
});
await waitForRun(runId);
const artifact = await db
.selectFrom("ci_artifacts")
.select("id")
.where("run_id", "=", runId)
.executeTakeFirst();
artifactId = artifact?.id ?? 0;
});
test("artifact row created in DB", async () => {
const artifacts = await db
.selectFrom("ci_artifacts")
.selectAll()
.where("run_id", "=", runId)
.execute();
expect(artifacts.length).toBe(1);
expect(artifacts[0]!.filename).toBe("output.txt");
});
test("artifact is downloadable via HTTP", async () => {
expect(artifactId).toBeGreaterThan(0);
const resp = await fetch(
`${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`,
);
expect(resp.status).toBe(200);
const body = await resp.text();
expect(body).toBe("artifact-content-123");
});
test("run detail page shows artifact list", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-artifact-item").count(),
).toBeGreaterThan(0);
expect(
await page.locator(".ci-artifact-name").textContent(),
).toContain("output.txt");
} finally {
await page.close();
}
});
});
describe("badge", () => {
test("badge SVG returns success status after successful run", async () => {
const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`);
expect(resp.status).toBe(200);
expect(resp.headers.get("Content-Type")).toContain("image/svg+xml");
const body = await resp.text();
expect(body).toContain("<svg");
expect(body).toContain("success");
});
test("badge returns 404 for private repo when not logged in", async () => {
// Create a private repo
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill("[name=name]", "private-ci-repo");
await page.check("[name=is_private]");
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/private-ci-repo`);
} finally {
await page.close();
}
const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`);
expect(resp.status).toBe(404);
});
});
describe("secrets", () => {
test("can add, list, and delete a secret via settings", async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/settings`);
// Add secret — scope to the CI secrets form
const secretsForm = page.locator('form[action$="/settings/ci-secrets"]');
await secretsForm.locator('[name=name]').fill("MY_SECRET");
await secretsForm.locator('[name=value]').fill("super-secret-value");
await secretsForm.locator('[name=description]').fill("A test secret");
await secretsForm.locator('button[type=submit]').click();
await page.waitForURL(/settings/);
// Secret name is shown, value masked
expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1);
expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0);
// Delete it
const deleteBtn = page
.locator(".label-settings-item")
.filter({ hasText: "MY_SECRET" })
.locator('button:text("Delete")');
await deleteBtn.click();
await page.waitForURL(/settings/);
expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0);
} finally {
await page.close();
}
});
test("secret value is masked in step logs", async () => {
// Add secret
await db
.insertInto("ci_secrets")
.values({
repo_id: (await db
.selectFrom("repositories")
.select("id")
.where("name", "=", "ci-repo")
.executeTakeFirstOrThrow()).id,
name: "MASK_ME",
value: "s3cr3t-p4ssw0rd",
})
.execute();
// Step echoes the secret value; mock returns it as output
queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 });
const runId = await triggerRun("ci-repo", {
triggerSource: "manual",
commitSha: ciRepoSha,
commitBranch: "main",
triggeredBy: adminUserId,
});
await waitForRun(runId);
const step = await db
.selectFrom("ci_steps")
.select("log")
.where("run_id", "=", runId)
.where("name", "=", "hello")
.executeTakeFirst();
expect(step?.log).not.toContain("s3cr3t-p4ssw0rd");
expect(step?.log).toContain("[MASKED]");
// Cleanup
await db
.deleteFrom("ci_secrets")
.where("name", "=", "MASK_ME")
.execute();
});
});
▾Mtests/e2e.issues.test.ts
@@ -257,7 +257,7 @@ describe('issue editing', () => {
try {
await page.goto(issueUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.click('.title-edit-open');
await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
▾Mtests/e2e.patches.test.ts
@@ -393,7 +393,7 @@ describe('patches', () => {
try {
await page.goto(conflictPatchUrl);
// Edit title via title form
await page.click('details.title-edit-details summary');
await page.click('.title-edit-open');
await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
await page.click('.title-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));