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
106106 ## Roadmap
107107
108108 - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
109-- More repository manipulation through the UI — file/directory/branch creation, renaming, and deletion
110-- Remove test retry logic once Bun no longer randomly stalls
109+- Remove test retry logic once Bun no longer randomly stalls
Mbun.lock
@@ -19,6 +19,7 @@
1919 "marked": "^17.0.4",
2020 "sharp": "^0.34.5",
2121 "shiki": "^4.0.2",
22+ "smol-toml": "^1.6.1",
2223 "ssh2": "^1.17.0",
2324 },
2425 "devDependencies": {
@@ -407,6 +408,8 @@
407408
408409 "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=="],
409410
411+ "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
412+
410413 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
411414
412415 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
Mpackage.json
@@ -45,6 +45,7 @@
4545 "marked": "^17.0.4",
4646 "sharp": "^0.34.5",
4747 "shiki": "^4.0.2",
48+ "smol-toml": "^1.6.1",
4849 "ssh2": "^1.17.0"
4950 }
5051 }
Apublic/assets/hearthforge-ci-template.toml
@@ -0,0 +1,51 @@
1+# HearthForge CI Pipeline Template
2+# Place this file at .hearthforge-ci.toml in your repository root.
3+
4+image = "docker.io/debian:stable"
5+work_dir = "/ci/build"
6+clone_project_to = "/ci/build/project"
7+# shell = ["/bin/sh", "-c"]
8+shell_setup = "set -euo pipefail"
9+# timeout = 3600 # overall run timeout in seconds
10+# cpu_limit = 2.0 # CPU cores limit
11+# memory_limit = "2g" # memory limit (k/m/g suffix)
12+# cache = ["/root/.cargo", "/root/.npm"] # persist between runs
13+
14+[on]
15+push = ["main"] # trigger on push to these branches; use ["*"] for all
16+tag = false # trigger on tag push
17+manual = true # allow manual trigger from the UI
18+
19+[variables]
20+ # [variables.MY_VAR]
21+ # default = "hello"
22+ # description = "A custom variable, overridable from the UI"
23+
24+# Steps are executed in file order. Each [section] is one step.
25+# Reserved section names: [on], [variables]
26+
27+[setup]
28+run_sh = "apt-get update -qq && apt-get install -y --no-install-recommends build-essential"
29+timeout = 180
30+
31+[build]
32+run_sh = "make -C project all"
33+
34+[test]
35+# run_if is a shell expression; the step is skipped if it returns non-zero
36+# run_if = 'test -n "${CI_COMMIT_TAG}"'
37+run_sh = "make -C project test"
38+
39+[package]
40+run_sh = "make -C project dist"
41+# Publish artifacts — these can appear in any step
42+# publish_file: copy a single file directly
43+# publish_file = ["/ci/build/project/dist/my-binary"]
44+# publish_gzip: create a .tar.gz archive (requires tar in image)
45+# publish_gzip = ["/ci/build/project/dist/"]
46+# publish_tar: create a plain .tar archive
47+# publish_tar = ["/ci/build/project/dist/"]
48+# publish_zip: create a .zip archive (requires zip in image)
49+# publish_zip = ["/ci/build/project/dist/"]
50+# publish_zstd: create a .tar.zst archive (requires tar + zstd in image)
51+# publish_zstd = ["/ci/build/project/dist/"]
Mscripts/test.ts
@@ -8,7 +8,7 @@ const files = readdirSync(testsDir)
88 .sort();
99
1010 const STALL_TIMEOUT = 20_000; // kill if no output for 20s
11-const MAX_RETRIES = 2;
11+const MAX_RETRIES = 3;
1212 // retry logic needed because tests get randomly get stuck on startup with bun
1313 // strace shows bun completely spinning in futex and not doing anything else
1414 function runTest(filePath: string): Promise<boolean> {
Msrc/app.ts
@@ -4,6 +4,7 @@ import { Elysia } from "elysia";
44 import config from "./config.ts";
55 import { authRoutes } from "./routes/auth.tsx";
66 import { avatarRoutes } from "./routes/avatars.ts";
7+import { ciRoutes } from "./routes/ci.tsx";
78 import { gitRoutes } from "./routes/git.ts";
89 import { issueRoutes } from "./routes/issues.tsx";
910 import { patchRoutes } from "./routes/patches.tsx";
@@ -30,6 +31,7 @@ export async function createApp(port: number) {
3031 .use(issueRoutes)
3132 .use(patchRoutes)
3233 .use(releasesRoutes)
34+ .use(ciRoutes)
3335 .use(avatarRoutes)
3436 .listen(port);
3537 }
Msrc/config.ts
@@ -31,6 +31,10 @@ const config = {
3131 MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000,
3232 MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64,
3333 MAX_PASSWORD_BYTES: parseInt(env.MAX_PASSWORD_BYTES ?? "", 10) || 1024,
34+ CI_DOCKER_SOCKET: env.CI_DOCKER_SOCKET ?? "",
35+ CI_MAX_HISTORY: parseInt(env.CI_MAX_HISTORY ?? "", 10) || 50,
36+ CI_MAX_CONCURRENT: parseInt(env.CI_MAX_CONCURRENT ?? "", 10) || 2,
37+ CI_DEFAULT_TIMEOUT: parseInt(env.CI_DEFAULT_TIMEOUT ?? "", 10) || 3600,
3438 };
3539
3640 // Derived values that depend on other config fields
Msrc/constants.ts
@@ -64,6 +64,7 @@ export const COMMITS_PER_PAGE = 20;
6464 export const ISSUES_PER_PAGE = 20;
6565 export const PATCHES_PER_PAGE = 20;
6666 export const RELEASES_PER_PAGE = 20;
67+export const CI_RUNS_PER_PAGE = 20;
6768 export const BRANCHES_PER_PAGE = 30;
6869 export const TAGS_PER_PAGE = 30;
6970
@@ -99,4 +100,7 @@ export const paths = {
99100 get ALLOWED_SIGNERS_PATH() {
100101 return path.join(config.DATA_DIR, "allowed_signers");
101102 },
103+ get CI_ARTIFACTS_DIR() {
104+ return path.join(config.DATA_DIR, "ci", "artifacts");
105+ },
102106 };
Msrc/db/index.ts
@@ -154,6 +154,49 @@ interface PatchLabelTable {
154154 label_id: number;
155155 }
156156
157+interface CiRunTable {
158+ id: Generated<number>;
159+ repo_id: number;
160+ triggered_by: number | null;
161+ trigger_source: string;
162+ commit_sha: string | null;
163+ commit_branch: string | null;
164+ commit_tag: string | null;
165+ status: string;
166+ variable_overrides: string | null;
167+ started_at: string | null;
168+ finished_at: string | null;
169+ created_at: Generated<string>;
170+}
171+
172+interface CiStepTable {
173+ id: Generated<number>;
174+ run_id: number;
175+ name: string;
176+ status: string;
177+ exit_code: number | null;
178+ started_at: string | null;
179+ finished_at: string | null;
180+ log: Generated<string>;
181+}
182+
183+interface CiArtifactTable {
184+ id: Generated<number>;
185+ run_id: number;
186+ filename: string;
187+ size: number;
188+ created_at: Generated<string>;
189+}
190+
191+interface CiSecretTable {
192+ id: Generated<number>;
193+ repo_id: number;
194+ name: string;
195+ value: string;
196+ description: string | null;
197+ created_at: Generated<string>;
198+}
199+
157200 export interface Database {
158201 users: UserTable;
159202 passkeys: PasskeyTable;
@@ -171,6 +214,10 @@ export interface Database {
171214 labels: LabelTable;
172215 issue_labels: IssueLabelTable;
173216 patch_labels: PatchLabelTable;
217+ ci_runs: CiRunTable;
218+ ci_steps: CiStepTable;
219+ ci_artifacts: CiArtifactTable;
220+ ci_secrets: CiSecretTable;
174221 }
175222
176223 // Selectable row types (id is plain number, as returned by queries)
@@ -188,6 +235,10 @@ export type SshKeyRow = Selectable<SshKeyTable>;
188235 export type ReleaseRow = Selectable<ReleaseTable>;
189236 export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
190237 export type LabelRow = Selectable<LabelTable>;
238+export type CiRunRow = Selectable<CiRunTable>;
239+export type CiStepRow = Selectable<CiStepTable>;
240+export type CiArtifactRow = Selectable<CiArtifactTable>;
241+export type CiSecretRow = Selectable<CiSecretTable>;
191242
192243 let sqlite = new BunDatabase(paths.DB_PATH);
193244 sqlite.run("PRAGMA journal_mode=WAL");
@@ -236,6 +287,48 @@ export function resetDb() {
236287 });
237288 }
238289
290+// Migration: create CI tables if missing
291+sqlite.run(`CREATE TABLE IF NOT EXISTS ci_runs (
292+ id INTEGER PRIMARY KEY AUTOINCREMENT,
293+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
294+ triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
295+ trigger_source TEXT NOT NULL,
296+ commit_sha TEXT,
297+ commit_branch TEXT,
298+ commit_tag TEXT,
299+ status TEXT NOT NULL DEFAULT 'pending',
300+ variable_overrides TEXT,
301+ started_at TEXT,
302+ finished_at TEXT,
303+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
304+)`);
305+sqlite.run(`CREATE TABLE IF NOT EXISTS ci_steps (
306+ id INTEGER PRIMARY KEY AUTOINCREMENT,
307+ run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
308+ name TEXT NOT NULL,
309+ status TEXT NOT NULL DEFAULT 'pending',
310+ exit_code INTEGER,
311+ started_at TEXT,
312+ finished_at TEXT,
313+ log TEXT NOT NULL DEFAULT ''
314+)`);
315+sqlite.run(`CREATE TABLE IF NOT EXISTS ci_artifacts (
316+ id INTEGER PRIMARY KEY AUTOINCREMENT,
317+ run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
318+ filename TEXT NOT NULL,
319+ size INTEGER NOT NULL,
320+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
321+)`);
322+sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
323+ id INTEGER PRIMARY KEY AUTOINCREMENT,
324+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
325+ name TEXT NOT NULL,
326+ value TEXT NOT NULL,
327+ description TEXT,
328+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
329+ UNIQUE(repo_id, name)
330+)`);
331+
239332 // Migration: add allow_user_labels column to repositories if missing
240333 const repoCols = sqlite
241334 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
Msrc/db/schema.sql
@@ -157,3 +157,47 @@ CREATE TABLE IF NOT EXISTS patch_labels (
157157 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
158158 PRIMARY KEY (patch_id, label_id)
159159 );
160+
161+CREATE TABLE IF NOT EXISTS ci_runs (
162+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
164+ triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
165+ trigger_source TEXT NOT NULL,
166+ commit_sha TEXT,
167+ commit_branch TEXT,
168+ commit_tag TEXT,
169+ status TEXT NOT NULL DEFAULT 'pending',
170+ variable_overrides TEXT,
171+ started_at TEXT,
172+ finished_at TEXT,
173+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
174+);
175+
176+CREATE TABLE IF NOT EXISTS ci_steps (
177+ id INTEGER PRIMARY KEY AUTOINCREMENT,
178+ run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
179+ name TEXT NOT NULL,
180+ status TEXT NOT NULL DEFAULT 'pending',
181+ exit_code INTEGER,
182+ started_at TEXT,
183+ finished_at TEXT,
184+ log TEXT NOT NULL DEFAULT ''
185+);
186+
187+CREATE TABLE IF NOT EXISTS ci_artifacts (
188+ id INTEGER PRIMARY KEY AUTOINCREMENT,
189+ run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
190+ filename TEXT NOT NULL,
191+ size INTEGER NOT NULL,
192+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
193+);
194+
195+CREATE TABLE IF NOT EXISTS ci_secrets (
196+ id INTEGER PRIMARY KEY AUTOINCREMENT,
197+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
198+ name TEXT NOT NULL,
199+ value TEXT NOT NULL,
200+ description TEXT,
201+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
202+ UNIQUE(repo_id, name)
203+);
Msrc/routes/auth.tsx
@@ -151,7 +151,13 @@ export const authRoutes = new Elysia()
151151 status: 403,
152152 });
153153 const ip = getClientIp(request, server);
154- if (!checkRateLimit(ip, REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS)) {
154+ if (
155+ !checkRateLimit(
156+ ip,
157+ REGISTRATION_MAX_ATTEMPTS,
158+ REGISTRATION_RATE_WINDOW_MS,
159+ )
160+ ) {
155161 return html(
156162 <Register
157163 error="Too many registration attempts. Please try again later."
Asrc/routes/ci.tsx
@@ -0,0 +1,506 @@
1+import { createReadStream, existsSync } from "node:fs";
2+import path from "node:path";
3+import { Elysia, t } from "elysia";
4+import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
5+import { db, getRepo } from "../db/index.ts";
6+import { requireAdmin, resolveSession } from "../middleware/session.ts";
7+import {
8+ cancelRun,
9+ parseCiConfig,
10+ shouldTriggerPush,
11+ shouldTriggerTag,
12+ triggerRun,
13+} from "../services/ci.ts";
14+import { git } from "../services/git.ts";
15+import { CiHistory } from "../views/ci/CiHistory.tsx";
16+import { CiRunDetail } from "../views/ci/CiRunDetail.tsx";
17+import { html } from "../views/render.tsx";
18+
19+/** Generate an SVG badge for CI status */
20+function makeBadge(status: string): string {
21+ const colors: Record<string, string> = {
22+ success: "#4c1",
23+ failure: "#e05d44",
24+ running: "#007ec6",
25+ pending: "#9f9f9f",
26+ cancelled: "#9f9f9f",
27+ };
28+ const color = colors[status] ?? "#9f9f9f";
29+ const label = "pipeline";
30+ const value = status;
31+ const labelWidth = label.length * 6 + 10;
32+ const valueWidth = value.length * 6 + 10;
33+ const totalWidth = labelWidth + valueWidth;
34+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
35+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
36+ <clipPath id="r"><rect width="${totalWidth}" height="20" rx="3"/></clipPath>
37+ <g clip-path="url(#r)">
38+ <rect width="${labelWidth}" height="20" fill="#555"/>
39+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
40+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
41+ </g>
42+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
43+ <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
44+ <text x="${labelWidth / 2}" y="14">${label}</text>
45+ <text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${value}</text>
46+ <text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
47+ </g>
48+</svg>`;
49+}
50+
51+export const ciRoutes = new Elysia()
52+ .guard({
53+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
54+ })
55+
56+ // Badge — no auth required for public repos
57+ .get("/:repo/ci/badge.svg", async ({ params }) => {
58+ const repo = await db
59+ .selectFrom("repositories")
60+ .select(["id", "is_private"])
61+ .where("name", "=", params.repo)
62+ .executeTakeFirst();
63+ if (!repo || repo.is_private) {
64+ return new Response("Not found", { status: 404 });
65+ }
66+ const latestRun = await db
67+ .selectFrom("ci_runs")
68+ .select("status")
69+ .where("repo_id", "=", repo.id)
70+ .orderBy("id", "desc")
71+ .limit(1)
72+ .executeTakeFirst();
73+ const status = latestRun?.status ?? "no builds";
74+ return new Response(makeBadge(status), {
75+ headers: {
76+ "Content-Type": "image/svg+xml",
77+ "Cache-Control": "no-cache",
78+ },
79+ });
80+ })
81+
82+ // Run history
83+ .get(
84+ "/:repo/ci",
85+ async ({ params, query, cookie }) => {
86+ const user = await resolveSession(cookie.session.value);
87+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
88+ if (!repo) return new Response("Not found", { status: 404 });
89+
90+ const page = Math.max(1, query.page ?? 1);
91+
92+ const countRow = await db
93+ .selectFrom("ci_runs")
94+ .select(db.fn.countAll<number>().as("count"))
95+ .where("repo_id", "=", repo.id)
96+ .executeTakeFirst();
97+ const totalPages = Math.max(
98+ 1,
99+ Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE),
100+ );
101+ const safePage = Math.min(page, totalPages);
102+ const offset = (safePage - 1) * CI_RUNS_PER_PAGE;
103+
104+ const runs = await db
105+ .selectFrom("ci_runs")
106+ .leftJoin("users", "users.id", "ci_runs.triggered_by")
107+ .select([
108+ "ci_runs.id",
109+ "ci_runs.status",
110+ "ci_runs.trigger_source",
111+ "ci_runs.commit_sha",
112+ "ci_runs.commit_branch",
113+ "ci_runs.commit_tag",
114+ "ci_runs.started_at",
115+ "ci_runs.finished_at",
116+ "ci_runs.created_at",
117+ "users.username as triggered_by_username",
118+ ])
119+ .where("repo_id", "=", repo.id)
120+ .orderBy("ci_runs.id", "desc")
121+ .limit(CI_RUNS_PER_PAGE)
122+ .offset(offset)
123+ .execute();
124+
125+ // Artifact counts per run
126+ const runIds = runs.map((r) => r.id);
127+ const artifactCounts =
128+ runIds.length > 0
129+ ? await db
130+ .selectFrom("ci_artifacts")
131+ .select([
132+ "run_id",
133+ db.fn.countAll<number>().as("count"),
134+ ])
135+ .where("run_id", "in", runIds)
136+ .groupBy("run_id")
137+ .execute()
138+ : [];
139+ const artifactCountMap = new Map(
140+ artifactCounts.map((r) => [r.run_id, Number(r.count)]),
141+ );
142+
143+ const runsWithCounts = runs.map((r) => ({
144+ ...r,
145+ artifact_count: artifactCountMap.get(r.id) ?? 0,
146+ }));
147+
148+ // Determine why manual trigger may be unavailable (admin-only check)
149+ let manualTriggerDisabledReason: string | null = null;
150+ if (user?.isAdmin) {
151+ const branches = await git.branches(repo.name);
152+ const defaultBranch = repo.default_branch || branches[0];
153+ if (!defaultBranch) {
154+ manualTriggerDisabledReason = "No branches — push a commit first";
155+ } else {
156+ const headLog = await git.log(repo.name, defaultBranch, 1);
157+ if (!headLog.length) {
158+ manualTriggerDisabledReason = "No commits yet";
159+ } else {
160+ const tomlBuf = await git.show(
161+ repo.name,
162+ headLog[0]!.hash,
163+ ".hearthforge-ci.toml",
164+ );
165+ if (!tomlBuf) {
166+ manualTriggerDisabledReason =
167+ "No .hearthforge-ci.toml found in repository";
168+ } else {
169+ const cfg = parseCiConfig(
170+ tomlBuf.toString("utf-8"),
171+ );
172+ if (!cfg) {
173+ manualTriggerDisabledReason =
174+ "Failed to parse .hearthforge-ci.toml";
175+ } else if (!cfg.on?.manual) {
176+ manualTriggerDisabledReason =
177+ 'Add manual = true under [on] to enable manual runs';
178+ }
179+ }
180+ }
181+ }
182+ }
183+
184+ return html(
185+ <CiHistory
186+ user={user}
187+ repo={repo}
188+ runs={runsWithCounts}
189+ pagination={{
190+ page: safePage,
191+ totalPages,
192+ pageUrlTemplate: `/${repo.name}/ci?page={page}`,
193+ }}
194+ manualTriggerDisabledReason={manualTriggerDisabledReason}
195+ />,
196+ );
197+ },
198+ { query: t.Object({ page: t.Optional(t.Number()) }) },
199+ )
200+
201+ // Run detail
202+ .get("/:repo/ci/:runId", async ({ params, cookie }) => {
203+ const user = await resolveSession(cookie.session.value);
204+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
205+ if (!repo) return new Response("Not found", { status: 404 });
206+
207+ const runId = Number(params.runId);
208+ const run = await db
209+ .selectFrom("ci_runs")
210+ .leftJoin("users", "users.id", "ci_runs.triggered_by")
211+ .select([
212+ "ci_runs.id",
213+ "ci_runs.status",
214+ "ci_runs.trigger_source",
215+ "ci_runs.commit_sha",
216+ "ci_runs.commit_branch",
217+ "ci_runs.commit_tag",
218+ "ci_runs.variable_overrides",
219+ "ci_runs.started_at",
220+ "ci_runs.finished_at",
221+ "ci_runs.created_at",
222+ "users.username as triggered_by_username",
223+ ])
224+ .where("ci_runs.id", "=", runId)
225+ .where("ci_runs.repo_id", "=", repo.id)
226+ .executeTakeFirst();
227+ if (!run) return new Response("Not found", { status: 404 });
228+
229+ const steps = await db
230+ .selectFrom("ci_steps")
231+ .selectAll()
232+ .where("run_id", "=", runId)
233+ .orderBy("id", "asc")
234+ .execute();
235+
236+ const artifacts = await db
237+ .selectFrom("ci_artifacts")
238+ .selectAll()
239+ .where("run_id", "=", runId)
240+ .orderBy("id", "asc")
241+ .execute();
242+
243+ return html(
244+ <CiRunDetail
245+ user={user}
246+ repo={repo}
247+ run={run}
248+ steps={steps}
249+ artifacts={artifacts}
250+ />,
251+ );
252+ })
253+
254+ // Manual trigger
255+ .post("/:repo/ci/run", async ({ params, body, cookie }) => {
256+ const user = await resolveSession(cookie.session.value);
257+ const deny = requireAdmin(user);
258+ if (deny) return deny;
259+ const repo = await getRepo(params.repo, true);
260+ if (!repo) return new Response("Not found", { status: 404 });
261+
262+ // Read CI config at HEAD to check manual trigger is allowed and get variable definitions
263+ const branches = await git.branches(repo.name);
264+ const defaultBranch = repo.default_branch || branches[0];
265+ if (!defaultBranch) return new Response("No branches", { status: 400 });
266+
267+ const headLog = await git.log(repo.name, defaultBranch, 1);
268+ if (!headLog.length) return new Response("No commits", { status: 400 });
269+ const headSha = headLog[0]!.hash;
270+
271+ const tomlBuf = await git.show(
272+ repo.name,
273+ headSha,
274+ ".hearthforge-ci.toml",
275+ );
276+ if (!tomlBuf)
277+ return new Response(
278+ "No .hearthforge-ci.toml found at HEAD. Add one to your repository to use CI pipelines.",
279+ { status: 400 },
280+ );
281+ const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
282+ if (!cfg)
283+ return new Response(
284+ "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
285+ { status: 400 },
286+ );
287+ if (!cfg.on?.manual)
288+ return new Response(
289+ 'Manual triggers are not enabled. Add manual = true under [on] in .hearthforge-ci.toml.',
290+ { status: 400 },
291+ );
292+
293+ // Parse variable overrides from form body
294+ const variableOverrides: Record<string, string> = {};
295+ if (cfg.variables) {
296+ for (const varName of Object.keys(cfg.variables)) {
297+ const formKey = `var_${varName}`;
298+ const val = (body as Record<string, string>)[formKey];
299+ if (typeof val === "string") {
300+ variableOverrides[varName] = val;
301+ }
302+ }
303+ }
304+
305+ const runId = await triggerRun(repo.name, {
306+ triggerSource: "manual",
307+ commitSha: headSha,
308+ commitBranch: defaultBranch,
309+ triggeredBy: user!.id,
310+ variableOverrides,
311+ });
312+
313+ return new Response(null, {
314+ status: 302,
315+ headers: { Location: `/${repo.name}/ci/${runId}` },
316+ });
317+ })
318+
319+ // Retry
320+ .post("/:repo/ci/:runId/retry", async ({ params, cookie }) => {
321+ const user = await resolveSession(cookie.session.value);
322+ const deny = requireAdmin(user);
323+ if (deny) return deny;
324+ const repo = await getRepo(params.repo, true);
325+ if (!repo) return new Response("Not found", { status: 404 });
326+
327+ const runId = Number(params.runId);
328+ const original = await db
329+ .selectFrom("ci_runs")
330+ .selectAll()
331+ .where("id", "=", runId)
332+ .where("repo_id", "=", repo.id)
333+ .executeTakeFirst();
334+ if (!original) return new Response("Not found", { status: 404 });
335+
336+ const newRunId = await triggerRun(repo.name, {
337+ triggerSource: original.trigger_source as "push" | "tag" | "manual",
338+ commitSha: original.commit_sha ?? "",
339+ commitBranch: original.commit_branch ?? undefined,
340+ commitTag: original.commit_tag ?? undefined,
341+ triggeredBy: user!.id,
342+ variableOverrides: original.variable_overrides
343+ ? JSON.parse(original.variable_overrides)
344+ : undefined,
345+ });
346+
347+ return new Response(null, {
348+ status: 302,
349+ headers: { Location: `/${repo.name}/ci/${newRunId}` },
350+ });
351+ })
352+
353+ // Cancel
354+ .post("/:repo/ci/:runId/cancel", async ({ params, cookie }) => {
355+ const user = await resolveSession(cookie.session.value);
356+ const deny = requireAdmin(user);
357+ if (deny) return deny;
358+ const repo = await getRepo(params.repo, true);
359+ if (!repo) return new Response("Not found", { status: 404 });
360+
361+ const runId = Number(params.runId);
362+ const run = await db
363+ .selectFrom("ci_runs")
364+ .select("id")
365+ .where("id", "=", runId)
366+ .where("repo_id", "=", repo.id)
367+ .executeTakeFirst();
368+ if (!run) return new Response("Not found", { status: 404 });
369+
370+ await cancelRun(runId);
371+
372+ return new Response(null, {
373+ status: 302,
374+ headers: { Location: `/${repo.name}/ci/${runId}` },
375+ });
376+ })
377+
378+ // Create secret
379+ .post("/:repo/settings/ci-secrets", async ({ params, body, cookie }) => {
380+ const user = await resolveSession(cookie.session.value);
381+ const deny = requireAdmin(user);
382+ if (deny) return deny;
383+ const repo = await db
384+ .selectFrom("repositories")
385+ .select("id")
386+ .where("name", "=", params.repo)
387+ .executeTakeFirst();
388+ if (!repo) return new Response("Not found", { status: 404 });
389+
390+ const name = (body as Record<string, string>).name?.trim();
391+ const value = (body as Record<string, string>).value;
392+ const description =
393+ (body as Record<string, string>).description?.trim() || null;
394+
395+ if (!name || !/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
396+ return new Response(null, {
397+ status: 302,
398+ headers: {
399+ Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret name must be a valid identifier.")}`,
400+ },
401+ });
402+ }
403+ if (!value) {
404+ return new Response(null, {
405+ status: 302,
406+ headers: {
407+ Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret value cannot be empty.")}`,
408+ },
409+ });
410+ }
411+
412+ await db
413+ .insertInto("ci_secrets")
414+ .values({
415+ repo_id: repo.id,
416+ name,
417+ value,
418+ description,
419+ })
420+ .onConflict((oc) =>
421+ oc
422+ .columns(["repo_id", "name"])
423+ .doUpdateSet({ value, description }),
424+ )
425+ .execute();
426+
427+ return new Response(null, {
428+ status: 302,
429+ headers: {
430+ Location: `/${params.repo}/settings?success=Secret+saved.`,
431+ },
432+ });
433+ })
434+
435+ // Delete secret
436+ .post(
437+ "/:repo/settings/ci-secrets/delete",
438+ async ({ params, body, cookie }) => {
439+ const user = await resolveSession(cookie.session.value);
440+ const deny = requireAdmin(user);
441+ if (deny) return deny;
442+ const repo = await db
443+ .selectFrom("repositories")
444+ .select("id")
445+ .where("name", "=", params.repo)
446+ .executeTakeFirst();
447+ if (!repo) return new Response("Not found", { status: 404 });
448+
449+ const id = Number((body as Record<string, string>).id);
450+ await db
451+ .deleteFrom("ci_secrets")
452+ .where("id", "=", id)
453+ .where("repo_id", "=", repo.id)
454+ .execute();
455+
456+ return new Response(null, {
457+ status: 302,
458+ headers: {
459+ Location: `/${params.repo}/settings?success=Secret+deleted.`,
460+ },
461+ });
462+ },
463+ )
464+
465+ // Artifact download
466+ .get(
467+ "/:repo/ci/:runId/artifacts/:artifactId",
468+ async ({ params, cookie }) => {
469+ const user = await resolveSession(cookie.session.value);
470+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
471+ if (!repo) return new Response("Not found", { status: 404 });
472+
473+ const runId = Number(params.runId);
474+ const artifactId = Number(params.artifactId);
475+
476+ const artifact = await db
477+ .selectFrom("ci_artifacts")
478+ .innerJoin("ci_runs", "ci_runs.id", "ci_artifacts.run_id")
479+ .select([
480+ "ci_artifacts.id",
481+ "ci_artifacts.filename",
482+ "ci_artifacts.size",
483+ ])
484+ .where("ci_artifacts.id", "=", artifactId)
485+ .where("ci_runs.id", "=", runId)
486+ .where("ci_runs.repo_id", "=", repo.id)
487+ .executeTakeFirst();
488+ if (!artifact) return new Response("Not found", { status: 404 });
489+
490+ const filePath = path.join(
491+ paths.CI_ARTIFACTS_DIR,
492+ String(runId),
493+ artifact.filename,
494+ );
495+ if (!existsSync(filePath))
496+ return new Response("File not found", { status: 404 });
497+
498+ return new Response(Bun.file(filePath), {
499+ headers: {
500+ "Content-Disposition": `attachment; filename="${artifact.filename}"`,
501+ "Content-Type": "application/octet-stream",
502+ "Content-Length": String(artifact.size),
503+ },
504+ });
505+ },
506+ );
Msrc/routes/git.ts
@@ -4,7 +4,83 @@ import * as argon2 from "argon2";
44 import { Elysia, t } from "elysia";
55 import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
66 import { db } from "../db";
7-import { invalidateRefCache } from "../services/git.ts";
7+import {
8+ parseCiConfig,
9+ shouldTriggerPush,
10+ shouldTriggerTag,
11+ triggerRun,
12+} from "../services/ci.ts";
13+import { git, invalidateRefCache } from "../services/git.ts";
14+
15+/** Parse ref updates from git receive-pack request body (pkt-line format). */
16+function parseRefUpdates(
17+ body: Uint8Array,
18+): Array<{ oldSha: string; newSha: string; refname: string }> {
19+ const text = new TextDecoder().decode(body.slice(0, 4096));
20+ const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
21+ let pos = 0;
22+ while (pos + 4 <= text.length) {
23+ const lenStr = text.slice(pos, pos + 4);
24+ const len = parseInt(lenStr, 16);
25+ if (Number.isNaN(len) || len === 0) break;
26+ if (len < 4 || pos + len > text.length) break;
27+ // Strip capabilities (after first NUL) and trim
28+ const line = text
29+ .slice(pos + 4, pos + len)
30+ .replace(/\0.*$/, "")
31+ .trim();
32+ pos += len;
33+ const parts = line.split(" ");
34+ if (parts.length >= 3) {
35+ const oldSha = parts[0] ?? "";
36+ const newSha = parts[1] ?? "";
37+ const refname = parts[2] ?? "";
38+ if (refname) refs.push({ oldSha, newSha, refname });
39+ }
40+ }
41+ return refs;
42+}
43+
44+/** Fire CI runs for any updated refs that match the pipeline config. */
45+async function triggerCiForPush(
46+ repoName: string,
47+ refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
48+): Promise<void> {
49+ for (const { newSha, refname } of refUpdates) {
50+ // Skip deletions
51+ if (/^0+$/.test(newSha)) continue;
52+
53+ const isBranch = refname.startsWith("refs/heads/");
54+ const isTag = refname.startsWith("refs/tags/");
55+ if (!isBranch && !isTag) continue;
56+
57+ const tomlBuf = await git
58+ .show(repoName, newSha, ".hearthforge-ci.toml")
59+ .catch(() => null);
60+ if (!tomlBuf) continue;
61+
62+ const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
63+ if (!cfg) continue;
64+
65+ if (isBranch) {
66+ const branch = refname.slice("refs/heads/".length);
67+ if (shouldTriggerPush(cfg, branch)) {
68+ triggerRun(repoName, {
69+ triggerSource: "push",
70+ commitSha: newSha,
71+ commitBranch: branch,
72+ }).catch(() => {});
73+ }
74+ } else if (isTag && shouldTriggerTag(cfg)) {
75+ const tag = refname.slice("refs/tags/".length);
76+ triggerRun(repoName, {
77+ triggerSource: "tag",
78+ commitSha: newSha,
79+ commitTag: tag,
80+ }).catch(() => {});
81+ }
82+ }
83+}
884
985 function pktLine(str: string): Buffer {
1086 const len = Buffer.byteLength(str, "utf-8") + 4;
@@ -51,7 +127,7 @@ function unauthorized(): Response {
51127
52128 async function getRepo(
53129 slug: string,
54-): Promise<{ repoPath: string; isPrivate: boolean } | null> {
130+): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> {
55131 const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
56132 if (!VALID_REPO_NAME_RE.test(repoName)) return null;
57133 const repo = await db
@@ -62,7 +138,7 @@ async function getRepo(
62138 if (!repo) return null;
63139 const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
64140 if (!existsSync(repoPath)) return null;
65- return { repoPath, isPrivate: repo.is_private === 1 };
141+ return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
66142 }
67143
68144 async function spawnGit(
@@ -165,11 +241,14 @@ export const gitRoutes = new Elysia()
165241 const repo = await getRepo(params.repo);
166242 if (!repo) return new Response("Not Found", { status: 404 });
167243 const body = new Uint8Array(await request.arrayBuffer());
244+ const refUpdates = parseRefUpdates(body);
168245 const result = await spawnGit(
169246 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
170247 body,
171248 );
172249 invalidateRefCache(repo.name);
250+ // Trigger CI in background — don't block the git push response
251+ triggerCiForPush(repo.name, refUpdates).catch(() => {});
173252 return new Response(result, {
174253 headers: {
175254 "Content-Type": "application/x-git-receive-pack-result",
Msrc/routes/repos.tsx
@@ -757,12 +757,20 @@ export const repoRoutes = new Elysia()
757757 const repo = await getRepo(params.repo, true);
758758 if (!repo) return new Response("Not found", { status: 404 });
759759 const branches = await git.branches(repo.name);
760- const labels = await db
761- .selectFrom("labels")
762- .selectAll()
763- .where("repo_id", "=", repo.id)
764- .orderBy("name", "asc")
765- .execute();
760+ const [labels, secrets] = await Promise.all([
761+ db
762+ .selectFrom("labels")
763+ .selectAll()
764+ .where("repo_id", "=", repo.id)
765+ .orderBy("name", "asc")
766+ .execute(),
767+ db
768+ .selectFrom("ci_secrets")
769+ .select(["id", "name", "description", "created_at"])
770+ .where("repo_id", "=", repo.id)
771+ .orderBy("name", "asc")
772+ .execute(),
773+ ]);
766774 const success =
767775 typeof query.success === "string" ? query.success : undefined;
768776 const error = typeof query.error === "string" ? query.error : undefined;
@@ -772,6 +780,7 @@ export const repoRoutes = new Elysia()
772780 repo={repo}
773781 branches={branches}
774782 labels={labels}
783+ secrets={secrets}
775784 success={success}
776785 error={error}
777786 />,
Asrc/services/ci.ts
@@ -0,0 +1,980 @@
1+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2+import path from "node:path";
3+import { parse as parseToml } from "smol-toml";
4+import config from "../config.ts";
5+import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
6+import { db } from "../db/index.ts";
7+import { repoPath } from "./git.ts";
8+
9+// --- Types ---
10+
11+interface CiVariableDef {
12+ default?: string;
13+ description?: string;
14+}
15+
16+interface CiStepConfig {
17+ run_sh?: string;
18+ run_if?: string;
19+ clear?: boolean;
20+ timeout?: number;
21+ publish_file?: string | string[];
22+ publish_tar?: string | string[];
23+ publish_gzip?: string | string[];
24+ publish_zip?: string | string[];
25+ publish_zstd?: string | string[];
26+}
27+
28+export interface CiStep extends CiStepConfig {
29+ name: string;
30+}
31+
32+export interface CiConfig {
33+ image: string;
34+ work_dir?: string;
35+ clone_project_to?: string;
36+ shell?: string[];
37+ shell_setup?: string;
38+ timeout?: number;
39+ cpu_limit?: number;
40+ memory_limit?: string;
41+ cache?: string[];
42+ on?: {
43+ push?: string[] | boolean;
44+ tag?: boolean;
45+ manual?: boolean;
46+ };
47+ variables?: Record<string, CiVariableDef>;
48+ steps: CiStep[];
49+}
50+
51+export interface TriggerOpts {
52+ triggerSource: "push" | "tag" | "manual";
53+ commitSha: string;
54+ commitBranch?: string;
55+ commitTag?: string;
56+ triggeredBy?: number;
57+ variableOverrides?: Record<string, string>;
58+}
59+
60+// Reserved TOML table names that are not steps
61+const RESERVED_TABLES = new Set(["on", "variables"]);
62+
63+// In-memory map of running tasks for cancellation
64+const runningTasks = new Map<
65+ number,
66+ { controller: AbortController; containerId?: string }
67+>();
68+
69+// --- TOML Parsing ---
70+
71+export function parseCiConfig(tomlStr: string): CiConfig | null {
72+ let raw: Record<string, unknown>;
73+ try {
74+ raw = parseToml(tomlStr) as Record<string, unknown>;
75+ } catch {
76+ return null;
77+ }
78+
79+ const image = raw.image;
80+ if (typeof image !== "string" || !image) return null;
81+
82+ const steps: CiStep[] = [];
83+ for (const [key, val] of Object.entries(raw)) {
84+ if (RESERVED_TABLES.has(key)) continue;
85+ if (typeof val !== "object" || val === null || Array.isArray(val))
86+ continue;
87+ // It's a table section — treat as a step
88+ const stepCfg = val as Record<string, unknown>;
89+ steps.push({ name: key, ...(stepCfg as CiStepConfig) });
90+ }
91+
92+ const rawOn = raw.on as Record<string, unknown> | undefined;
93+ const rawVars = raw.variables as
94+ | Record<string, Record<string, unknown>>
95+ | undefined;
96+ const variables: Record<string, CiVariableDef> = {};
97+ if (rawVars) {
98+ for (const [name, def] of Object.entries(rawVars)) {
99+ if (typeof def === "object" && def !== null) {
100+ variables[name] = {
101+ default:
102+ typeof def.default === "string"
103+ ? def.default
104+ : undefined,
105+ description:
106+ typeof def.description === "string"
107+ ? def.description
108+ : undefined,
109+ };
110+ }
111+ }
112+ }
113+
114+ return {
115+ image,
116+ work_dir: typeof raw.work_dir === "string" ? raw.work_dir : undefined,
117+ clone_project_to:
118+ typeof raw.clone_project_to === "string"
119+ ? raw.clone_project_to
120+ : undefined,
121+ shell: Array.isArray(raw.shell) ? (raw.shell as string[]) : undefined,
122+ shell_setup:
123+ typeof raw.shell_setup === "string" ? raw.shell_setup : undefined,
124+ timeout: typeof raw.timeout === "number" ? raw.timeout : undefined,
125+ cpu_limit:
126+ typeof raw.cpu_limit === "number" ? raw.cpu_limit : undefined,
127+ memory_limit:
128+ typeof raw.memory_limit === "string" ? raw.memory_limit : undefined,
129+ cache: Array.isArray(raw.cache) ? (raw.cache as string[]) : undefined,
130+ on: rawOn
131+ ? {
132+ push: Array.isArray(rawOn.push)
133+ ? (rawOn.push as string[])
134+ : typeof rawOn.push === "boolean"
135+ ? rawOn.push
136+ : undefined,
137+ tag: typeof rawOn.tag === "boolean" ? rawOn.tag : undefined,
138+ manual:
139+ typeof rawOn.manual === "boolean"
140+ ? rawOn.manual
141+ : undefined,
142+ }
143+ : undefined,
144+ variables,
145+ steps,
146+ };
147+}
148+
149+// --- Trigger matching ---
150+
151+export function shouldTriggerPush(cfg: CiConfig, branch: string): boolean {
152+ const pushCfg = cfg.on?.push;
153+ if (!pushCfg) return false;
154+ if (pushCfg === true) return true;
155+ if (Array.isArray(pushCfg)) {
156+ return pushCfg.some((pattern) => matchGlob(pattern, branch));
157+ }
158+ return false;
159+}
160+
161+export function shouldTriggerTag(cfg: CiConfig): boolean {
162+ return cfg.on?.tag === true;
163+}
164+
165+function matchGlob(pattern: string, value: string): boolean {
166+ if (pattern === "*") return true;
167+ const re = new RegExp(
168+ `^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`,
169+ );
170+ return re.test(value);
171+}
172+
173+// --- Docker socket ---
174+
175+let resolvedSocket: string | null = null;
176+
177+async function getSocket(): Promise<string> {
178+ if (resolvedSocket) return resolvedSocket;
179+ if (config.CI_DOCKER_SOCKET) {
180+ resolvedSocket = config.CI_DOCKER_SOCKET;
181+ return resolvedSocket;
182+ }
183+ const uid = process.getuid?.();
184+ const candidates = [
185+ "/var/run/docker.sock",
186+ "/run/podman/podman.sock",
187+ ...(uid !== undefined ? [`/run/user/${uid}/podman/podman.sock`] : []),
188+ ];
189+ for (const s of candidates) {
190+ if (existsSync(s)) {
191+ resolvedSocket = s;
192+ return s;
193+ }
194+ }
195+ throw new Error(
196+ "No Docker/Podman socket found. Set CI_DOCKER_SOCKET env var.",
197+ );
198+}
199+
200+async function dockerFetch(
201+ endpoint: string,
202+ init?: RequestInit,
203+): Promise<Response> {
204+ const socket = await getSocket();
205+ return fetch(`http://localhost/v1.47${endpoint}`, {
206+ ...init,
207+ unix: socket,
208+ });
209+}
210+
211+// --- Docker helpers ---
212+
213+function splitImageRef(image: string): { name: string; tag: string } {
214+ const lastColon = image.lastIndexOf(":");
215+ if (lastColon < 0) return { name: image, tag: "latest" };
216+ const possibleTag = image.slice(lastColon + 1);
217+ if (possibleTag.includes("/")) return { name: image, tag: "latest" };
218+ return { name: image.slice(0, lastColon), tag: possibleTag };
219+}
220+
221+async function pullImage(image: string): Promise<void> {
222+ const { name, tag } = splitImageRef(image);
223+ const resp = await dockerFetch(
224+ `/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`,
225+ { method: "POST" },
226+ );
227+ // Consume body to completion
228+ await resp.body?.cancel();
229+}
230+
231+function parseMemoryBytes(s: string): number {
232+ const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmgKMG]?)b?$/);
233+ if (!m) return 0;
234+ const n = parseFloat(m[1] ?? "0");
235+ switch ((m[2] ?? "").toLowerCase()) {
236+ case "k":
237+ return Math.floor(n * 1024);
238+ case "m":
239+ return Math.floor(n * 1024 * 1024);
240+ case "g":
241+ return Math.floor(n * 1024 * 1024 * 1024);
242+ default:
243+ return Math.floor(n);
244+ }
245+}
246+
247+async function createContainer(
248+ runId: number,
249+ cfg: CiConfig,
250+ repoAbsPath: string,
251+ envVars: string[],
252+): Promise<string> {
253+ const binds: string[] = [`${repoAbsPath}:/hearthforge-repo.git:ro`];
254+ if (cfg.cache) {
255+ for (const cachePath of cfg.cache) {
256+ const volName = `hearthforge-ci-cache-${Buffer.from(`${runId}-${cachePath}`).toString("base64url").slice(0, 24)}`;
257+ binds.push(`${volName}:${cachePath}`);
258+ }
259+ }
260+
261+ const hostConfig: Record<string, unknown> = { Binds: binds };
262+ if (cfg.cpu_limit) {
263+ hostConfig.NanoCpus = Math.floor(cfg.cpu_limit * 1e9);
264+ }
265+ if (cfg.memory_limit) {
266+ hostConfig.Memory = parseMemoryBytes(cfg.memory_limit);
267+ }
268+
269+ const body = JSON.stringify({
270+ Image: cfg.image,
271+ Cmd: ["sleep", "infinity"],
272+ Env: envVars,
273+ WorkingDir: cfg.work_dir ?? "/",
274+ HostConfig: hostConfig,
275+ });
276+
277+ const resp = await dockerFetch(
278+ `/containers/create?name=hearthforge-ci-${runId}`,
279+ {
280+ method: "POST",
281+ headers: { "Content-Type": "application/json" },
282+ body,
283+ },
284+ );
285+ if (!resp.ok) {
286+ const text = await resp.text();
287+ throw new Error(`Failed to create container: ${resp.status} ${text}`);
288+ }
289+ const data = (await resp.json()) as { Id: string };
290+ return data.Id;
291+}
292+
293+async function startContainer(containerId: string): Promise<void> {
294+ const resp = await dockerFetch(`/containers/${containerId}/start`, {
295+ method: "POST",
296+ });
297+ if (!resp.ok && resp.status !== 304) {
298+ throw new Error(`Failed to start container: ${resp.status}`);
299+ }
300+ await resp.body?.cancel();
301+}
302+
303+interface ExecResult {
304+ log: string;
305+ exitCode: number;
306+}
307+
308+function parseMuxStream(data: Uint8Array): string {
309+ const chunks: string[] = [];
310+ const dec = new TextDecoder();
311+ let i = 0;
312+ while (i + 8 <= data.length) {
313+ const view = new DataView(data.buffer, data.byteOffset + i, 8);
314+ const size = view.getUint32(4, false);
315+ i += 8;
316+ if (i + size > data.length) break;
317+ chunks.push(dec.decode(data.slice(i, i + size)));
318+ i += size;
319+ }
320+ return chunks.join("");
321+}
322+
323+async function execInContainer(
324+ containerId: string,
325+ cmd: string[],
326+ workDir?: string,
327+ envVars?: string[],
328+ signal?: AbortSignal,
329+): Promise<ExecResult> {
330+ // Create exec
331+ const execBody = JSON.stringify({
332+ Cmd: cmd,
333+ AttachStdout: true,
334+ AttachStderr: true,
335+ ...(workDir ? { WorkingDir: workDir } : {}),
336+ ...(envVars ? { Env: envVars } : {}),
337+ });
338+ const createResp = await dockerFetch(`/containers/${containerId}/exec`, {
339+ method: "POST",
340+ headers: { "Content-Type": "application/json" },
341+ body: execBody,
342+ signal,
343+ });
344+ if (!createResp.ok) {
345+ const text = await createResp.text();
346+ throw new Error(`Failed to create exec: ${createResp.status} ${text}`);
347+ }
348+ const createData = (await createResp.json()) as { Id: string };
349+ const execId = createData.Id;
350+
351+ // Start exec and capture output
352+ const startResp = await dockerFetch(`/exec/${execId}/start`, {
353+ method: "POST",
354+ headers: { "Content-Type": "application/json" },
355+ body: JSON.stringify({ Detach: false, Tty: false }),
356+ signal,
357+ });
358+ const bodyBytes = new Uint8Array(await startResp.arrayBuffer());
359+ const log = parseMuxStream(bodyBytes);
360+
361+ // Get exit code
362+ const inspectResp = await dockerFetch(`/exec/${execId}/json`);
363+ const inspectData = (await inspectResp.json()) as { ExitCode: number };
364+
365+ return { log, exitCode: inspectData.ExitCode ?? 1 };
366+}
367+
368+async function removeContainer(containerId: string): Promise<void> {
369+ try {
370+ const resp = await dockerFetch(
371+ `/containers/${containerId}?force=true`,
372+ { method: "DELETE" },
373+ );
374+ await resp.body?.cancel();
375+ } catch {
376+ // Best-effort cleanup
377+ }
378+}
379+
380+// --- Tar extraction ---
381+
382+function extractSingleFileFromTar(data: Uint8Array): Uint8Array | null {
383+ if (data.length < 512) return null;
384+ const dec = new TextDecoder();
385+ const sizeOctal = dec
386+ .decode(data.slice(124, 136))
387+ .replace(/\0/g, "")
388+ .trim();
389+ const size = parseInt(sizeOctal, 8);
390+ if (Number.isNaN(size) || size < 0) return null;
391+ if (data.length < 512 + size) return null;
392+ return data.slice(512, 512 + size);
393+}
394+
395+async function copyFileFromContainer(
396+ containerId: string,
397+ containerPath: string,
398+): Promise<Uint8Array | null> {
399+ const resp = await dockerFetch(
400+ `/containers/${containerId}/archive?path=${encodeURIComponent(containerPath)}`,
401+ );
402+ if (!resp.ok) return null;
403+ const tarBytes = new Uint8Array(await resp.arrayBuffer());
404+ return extractSingleFileFromTar(tarBytes);
405+}
406+
407+// --- Secret masking ---
408+
409+function maskSecrets(text: string, secrets: string[]): string {
410+ for (const secret of secrets) {
411+ if (secret) text = text.split(secret).join("[MASKED]");
412+ }
413+ return text;
414+}
415+
416+// --- Env var building ---
417+
418+function buildEnvVars(
419+ runId: number,
420+ repoName: string,
421+ opts: TriggerOpts,
422+ cfg: CiConfig,
423+ secretValues: Array<{ name: string; value: string }>,
424+): { envArray: string[]; secretValues: string[] } {
425+ const vars: Record<string, string> = {
426+ CI: "true",
427+ CI_PIPELINE_ID: String(runId),
428+ CI_REPO_NAME: repoName,
429+ CI_SERVER_URL: config.BASE_URL,
430+ CI_TRIGGER_SOURCE: opts.triggerSource,
431+ CI_COMMIT_SHA: opts.commitSha,
432+ CI_COMMIT_SHORT_SHA: opts.commitSha.slice(0, 8),
433+ CI_COMMIT_BRANCH: opts.commitBranch ?? "",
434+ CI_COMMIT_TAG: opts.commitTag ?? "",
435+ CI_COMMIT_REF_NAME: opts.commitTag ?? opts.commitBranch ?? "",
436+ };
437+
438+ // User-defined variable defaults
439+ if (cfg.variables) {
440+ for (const [name, def] of Object.entries(cfg.variables)) {
441+ if (def.default !== undefined) vars[name] = def.default;
442+ }
443+ }
444+
445+ // Variable overrides from manual trigger
446+ if (opts.variableOverrides) {
447+ for (const [name, value] of Object.entries(opts.variableOverrides)) {
448+ vars[name] = value;
449+ }
450+ }
451+
452+ // Secrets (injected but values tracked for masking)
453+ const secretVals: string[] = [];
454+ for (const { name, value } of secretValues) {
455+ vars[name] = value;
456+ secretVals.push(value);
457+ }
458+
459+ const envArray = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
460+ return { envArray, secretValues: secretVals };
461+}
462+
463+// --- Artifact collection ---
464+
465+async function collectArtifacts(
466+ runId: number,
467+ containerId: string,
468+ step: CiStep,
469+ shell: string[],
470+ workDir: string | undefined,
471+ envVars: string[],
472+): Promise<void> {
473+ const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId));
474+ mkdirSync(artifactDir, { recursive: true });
475+
476+ const toArray = (v: string | string[] | undefined): string[] => {
477+ if (!v) return [];
478+ return Array.isArray(v) ? v : [v];
479+ };
480+
481+ // publish_file: copy directly out of container
482+ for (const srcPath of toArray(step.publish_file)) {
483+ const fileBytes = await copyFileFromContainer(containerId, srcPath);
484+ if (fileBytes) {
485+ const filename = path.basename(srcPath);
486+ const destPath = path.join(artifactDir, filename);
487+ writeFileSync(destPath, fileBytes);
488+ const stat = Bun.file(destPath);
489+ await db
490+ .insertInto("ci_artifacts")
491+ .values({
492+ run_id: runId,
493+ filename,
494+ size: stat.size,
495+ })
496+ .execute();
497+ }
498+ }
499+
500+ type ArchiveType = "tar" | "gzip" | "zip" | "zstd";
501+ const archiveFormats: Array<{
502+ type: ArchiveType;
503+ paths: string[];
504+ ext: string;
505+ cmd: (src: string, dst: string) => string[];
506+ }> = [
507+ {
508+ type: "tar",
509+ paths: toArray(step.publish_tar),
510+ ext: ".tar",
511+ cmd: (src, dst) => [
512+ "tar",
513+ "-cf",
514+ dst,
515+ "-C",
516+ path.dirname(src),
517+ path.basename(src),
518+ ],
519+ },
520+ {
521+ type: "gzip",
522+ paths: toArray(step.publish_gzip),
523+ ext: ".tar.gz",
524+ cmd: (src, dst) => [
525+ "tar",
526+ "-czf",
527+ dst,
528+ "-C",
529+ path.dirname(src),
530+ path.basename(src),
531+ ],
532+ },
533+ {
534+ type: "zstd",
535+ paths: toArray(step.publish_zstd),
536+ ext: ".tar.zst",
537+ cmd: (src, dst) => [
538+ "tar",
539+ "--zstd",
540+ "-cf",
541+ dst,
542+ "-C",
543+ path.dirname(src),
544+ path.basename(src),
545+ ],
546+ },
547+ {
548+ type: "zip",
549+ paths: toArray(step.publish_zip),
550+ ext: ".zip",
551+ cmd: (src, dst) => [
552+ "sh",
553+ "-c",
554+ `cd ${path.dirname(src)} && zip -r ${dst} ${path.basename(src)}`,
555+ ],
556+ },
557+ ];
558+
559+ let archiveIndex = 0;
560+ for (const { paths: archivePaths, ext, cmd } of archiveFormats) {
561+ for (const srcPath of archivePaths) {
562+ archiveIndex++;
563+ const tmpPath = `/tmp/hf-artifact-${runId}-${archiveIndex}${ext}`;
564+ // Create archive inside container
565+ const execResult = await execInContainer(
566+ containerId,
567+ cmd(srcPath, tmpPath),
568+ workDir,
569+ envVars,
570+ ).catch(() => null);
571+ if (!execResult || execResult.exitCode !== 0) continue;
572+
573+ // Copy archive out
574+ const fileBytes = await copyFileFromContainer(containerId, tmpPath);
575+ if (!fileBytes) continue;
576+
577+ const filename = `${path.basename(srcPath)}${ext}`;
578+ const destPath = path.join(artifactDir, filename);
579+ writeFileSync(destPath, fileBytes);
580+ const stat = Bun.file(destPath);
581+ await db
582+ .insertInto("ci_artifacts")
583+ .values({
584+ run_id: runId,
585+ filename,
586+ size: stat.size,
587+ })
588+ .execute();
589+ }
590+ }
591+}
592+
593+// --- Main execution ---
594+
595+async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
596+ const now = () => new Date().toISOString();
597+ let containerId: string | undefined;
598+
599+ try {
600+ // Mark as running
601+ await db
602+ .updateTable("ci_runs")
603+ .set({ status: "running", started_at: now() })
604+ .where("id", "=", runId)
605+ .execute();
606+
607+ // Load run details
608+ const run = await db
609+ .selectFrom("ci_runs")
610+ .selectAll()
611+ .where("id", "=", runId)
612+ .executeTakeFirst();
613+ if (!run) throw new Error("Run not found");
614+
615+ const repo = await db
616+ .selectFrom("repositories")
617+ .select(["id", "name"])
618+ .where("id", "=", run.repo_id)
619+ .executeTakeFirst();
620+ if (!repo) throw new Error("Repo not found");
621+
622+ // Read .hearthforge-ci.toml at the commit
623+ const tomlBuf = await import("./git.ts").then((g) =>
624+ g.git.show(repo.name, run.commit_sha!, ".hearthforge-ci.toml"),
625+ );
626+ if (!tomlBuf)
627+ throw new Error(".hearthforge-ci.toml not found at commit");
628+
629+ const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
630+ if (!cfg) throw new Error("Failed to parse .hearthforge-ci.toml");
631+
632+ // Load secrets for log masking
633+ const secrets = await db
634+ .selectFrom("ci_secrets")
635+ .select(["name", "value"])
636+ .where("repo_id", "=", repo.id)
637+ .execute();
638+
639+ const variableOverrides = run.variable_overrides
640+ ? (JSON.parse(run.variable_overrides) as Record<string, string>)
641+ : {};
642+
643+ const { envArray, secretValues } = buildEnvVars(
644+ runId,
645+ repo.name,
646+ {
647+ triggerSource:
648+ run.trigger_source as TriggerOpts["triggerSource"],
649+ commitSha: run.commit_sha ?? "",
650+ commitBranch: run.commit_branch ?? undefined,
651+ commitTag: run.commit_tag ?? undefined,
652+ variableOverrides,
653+ },
654+ cfg,
655+ secrets,
656+ );
657+
658+ // Create step rows in DB
659+ for (const step of cfg.steps) {
660+ await db
661+ .insertInto("ci_steps")
662+ .values({
663+ run_id: runId,
664+ name: step.name,
665+ status: "pending",
666+ })
667+ .execute();
668+ }
669+
670+ // Pull image
671+ await pullImage(cfg.image);
672+ if (signal.aborted) throw new Error("Cancelled");
673+
674+ // Create + start container
675+ containerId = await createContainer(
676+ runId,
677+ cfg,
678+ repoPath(repo.name),
679+ envArray,
680+ );
681+ runningTasks.get(runId)!.containerId = containerId;
682+
683+ await startContainer(containerId);
684+ if (signal.aborted) throw new Error("Cancelled");
685+
686+ // Create work_dir
687+ if (cfg.work_dir) {
688+ await execInContainer(containerId, ["mkdir", "-p", cfg.work_dir]);
689+ }
690+
691+ // Clone project if requested
692+ if (cfg.clone_project_to && run.commit_sha) {
693+ await execInContainer(
694+ containerId,
695+ [
696+ "sh",
697+ "-c",
698+ `git clone /hearthforge-repo.git ${cfg.clone_project_to} && git -C ${cfg.clone_project_to} checkout --detach ${run.commit_sha}`,
699+ ],
700+ cfg.work_dir,
701+ envArray,
702+ );
703+ }
704+
705+ // Execute steps
706+ const shell = cfg.shell ?? ["/bin/sh", "-c"];
707+ let runFailed = false;
708+
709+ for (const step of cfg.steps) {
710+ if (signal.aborted) {
711+ runFailed = true;
712+ break;
713+ }
714+
715+ const stepRow = await db
716+ .selectFrom("ci_steps")
717+ .select("id")
718+ .where("run_id", "=", runId)
719+ .where("name", "=", step.name)
720+ .executeTakeFirst();
721+ if (!stepRow) continue;
722+ const stepId = stepRow.id;
723+
724+ // Check run_if condition
725+ if (step.run_if) {
726+ const { exitCode } = await execInContainer(
727+ containerId,
728+ [...shell, step.run_if],
729+ cfg.work_dir,
730+ envArray,
731+ );
732+ if (exitCode !== 0) {
733+ await db
734+ .updateTable("ci_steps")
735+ .set({
736+ status: "skipped",
737+ started_at: now(),
738+ finished_at: now(),
739+ })
740+ .where("id", "=", stepId)
741+ .execute();
742+ continue;
743+ }
744+ }
745+
746+ // Handle clear option
747+ if (step.clear && cfg.clone_project_to && run.commit_sha) {
748+ await execInContainer(
749+ containerId,
750+ [
751+ "sh",
752+ "-c",
753+ `git -C ${cfg.clone_project_to} reset --hard ${run.commit_sha} && git -C ${cfg.clone_project_to} clean -fdx`,
754+ ],
755+ cfg.work_dir,
756+ envArray,
757+ );
758+ }
759+
760+ await db
761+ .updateTable("ci_steps")
762+ .set({ status: "running", started_at: now() })
763+ .where("id", "=", stepId)
764+ .execute();
765+
766+ let stepLog = "";
767+ let stepStatus: "success" | "failure" = "success";
768+
769+ if (step.run_sh) {
770+ const command = cfg.shell_setup
771+ ? `${cfg.shell_setup}\n${step.run_sh}`
772+ : step.run_sh;
773+
774+ const stepTimeout =
775+ step.timeout ?? cfg.timeout ?? config.CI_DEFAULT_TIMEOUT;
776+ const timeoutSignal = AbortSignal.timeout(stepTimeout * 1000);
777+
778+ try {
779+ const { log, exitCode } = await execInContainer(
780+ containerId,
781+ [...shell, command],
782+ cfg.work_dir,
783+ envArray,
784+ timeoutSignal,
785+ );
786+ stepLog = maskSecrets(log, secretValues);
787+ if (exitCode !== 0) {
788+ stepStatus = "failure";
789+ runFailed = true;
790+ }
791+ } catch (err) {
792+ stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
793+ stepStatus = "failure";
794+ runFailed = true;
795+ }
796+ }
797+
798+ // Collect artifacts for this step
799+ if (!runFailed || stepStatus === "success") {
800+ await collectArtifacts(
801+ runId,
802+ containerId,
803+ step,
804+ shell,
805+ cfg.work_dir,
806+ envArray,
807+ ).catch(() => {});
808+ }
809+
810+ await db
811+ .updateTable("ci_steps")
812+ .set({
813+ status: stepStatus,
814+ finished_at: now(),
815+ log: stepLog,
816+ })
817+ .where("id", "=", stepId)
818+ .execute();
819+
820+ if (runFailed) break;
821+ }
822+
823+ // Mark remaining steps as skipped
824+ await db
825+ .updateTable("ci_steps")
826+ .set({ status: "skipped", started_at: now(), finished_at: now() })
827+ .where("run_id", "=", runId)
828+ .where("status", "=", "pending")
829+ .execute();
830+
831+ const finalStatus = runFailed ? "failure" : "success";
832+ await db
833+ .updateTable("ci_runs")
834+ .set({ status: finalStatus, finished_at: now() })
835+ .where("id", "=", runId)
836+ .execute();
837+ } catch (err) {
838+ const status = signal.aborted ? "cancelled" : "failure";
839+ const errMsg = err instanceof Error ? err.message : String(err);
840+ // Write error to a synthetic step if we have no steps yet
841+ const hasSteps = await db
842+ .selectFrom("ci_steps")
843+ .select("id")
844+ .where("run_id", "=", runId)
845+ .executeTakeFirst();
846+ if (!hasSteps) {
847+ await db
848+ .insertInto("ci_steps")
849+ .values({
850+ run_id: runId,
851+ name: "setup",
852+ status: "failure",
853+ started_at: new Date().toISOString(),
854+ finished_at: new Date().toISOString(),
855+ log: `Error: ${errMsg}\n`,
856+ })
857+ .execute();
858+ }
859+ await db
860+ .updateTable("ci_runs")
861+ .set({ status, finished_at: new Date().toISOString() })
862+ .where("id", "=", runId)
863+ .execute();
864+ // Mark pending steps as skipped
865+ await db
866+ .updateTable("ci_steps")
867+ .set({ status: "skipped", finished_at: new Date().toISOString() })
868+ .where("run_id", "=", runId)
869+ .where("status", "=", "pending")
870+ .execute();
871+ } finally {
872+ if (containerId) await removeContainer(containerId);
873+ runningTasks.delete(runId);
874+ // Prune old history
875+ const run = await db
876+ .selectFrom("ci_runs")
877+ .select("repo_id")
878+ .where("id", "=", runId)
879+ .executeTakeFirst();
880+ if (run) await pruneHistory(run.repo_id).catch(() => {});
881+ }
882+}
883+
884+// --- Public API ---
885+
886+export async function triggerRun(
887+ repoName: string,
888+ opts: TriggerOpts,
889+): Promise<number> {
890+ const repo = await db
891+ .selectFrom("repositories")
892+ .select("id")
893+ .where("name", "=", repoName)
894+ .executeTakeFirst();
895+ if (!repo) throw new Error("Repository not found");
896+
897+ const runId = await db
898+ .insertInto("ci_runs")
899+ .values({
900+ repo_id: repo.id,
901+ triggered_by: opts.triggeredBy ?? null,
902+ trigger_source: opts.triggerSource,
903+ commit_sha: opts.commitSha,
904+ commit_branch: opts.commitBranch ?? null,
905+ commit_tag: opts.commitTag ?? null,
906+ status: "pending",
907+ variable_overrides: opts.variableOverrides
908+ ? JSON.stringify(opts.variableOverrides)
909+ : null,
910+ })
911+ .returning("id")
912+ .executeTakeFirstOrThrow();
913+
914+ const controller = new AbortController();
915+ runningTasks.set(runId.id, { controller });
916+
917+ // Fire and forget — like release archiving
918+ (async () => {
919+ await executeRun(runId.id, controller.signal);
920+ })();
921+
922+ return runId.id;
923+}
924+
925+export async function cancelRun(runId: number): Promise<void> {
926+ const task = runningTasks.get(runId);
927+ if (task) {
928+ const { containerId } = task;
929+ task.controller.abort();
930+ if (containerId) {
931+ await removeContainer(containerId).catch(() => {});
932+ }
933+ }
934+ await db
935+ .updateTable("ci_runs")
936+ .set({ status: "cancelled", finished_at: new Date().toISOString() })
937+ .where("id", "=", runId)
938+ .where("status", "in", ["pending", "running"])
939+ .execute();
940+}
941+
942+async function pruneHistory(repoId: number): Promise<void> {
943+ const maxHistory = config.CI_MAX_HISTORY;
944+ const allRuns = await db
945+ .selectFrom("ci_runs")
946+ .select("id")
947+ .where("repo_id", "=", repoId)
948+ .orderBy("id", "desc")
949+ .execute();
950+
951+ if (allRuns.length <= maxHistory) return;
952+
953+ const toDelete = allRuns.slice(maxHistory).map((r) => r.id);
954+ for (const runId of toDelete) {
955+ // Remove artifacts from disk
956+ const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId));
957+ if (existsSync(artifactDir)) {
958+ await Bun.$`rm -rf ${artifactDir}`.quiet().nothrow();
959+ }
960+ }
961+ await db.deleteFrom("ci_runs").where("id", "in", toDelete).execute();
962+}
963+
964+/** Reset the cached socket path (used in tests to switch mock sockets). */
965+export function resetDockerSocket(): void {
966+ resolvedSocket = null;
967+}
968+
969+/** Check if CI can connect to the container socket. */
970+export async function ciAvailable(): Promise<boolean> {
971+ try {
972+ const socket = await getSocket();
973+ const resp = await fetch("http://localhost/v1.47/info", {
974+ unix: socket,
975+ });
976+ return resp.ok;
977+ } catch {
978+ return false;
979+ }
980+}
Msrc/services/markdown.ts
@@ -1,6 +1,10 @@
11 import DOMPurify from "isomorphic-dompurify";
22 import { Marked, marked, type Tokens } from "marked";
3-import { MAX_MD_CACHE, PREVIEW_MAX_LENGTH, PREVIEW_TRUNCATION_THRESHOLD } from "../constants.ts";
3+import {
4+ MAX_MD_CACHE,
5+ PREVIEW_MAX_LENGTH,
6+ PREVIEW_TRUNCATION_THRESHOLD,
7+} from "../constants.ts";
48
59 marked.setOptions({ gfm: true });
610
@@ -181,14 +185,18 @@ export function markdownToPlaintext(md: string): string {
181185 * Returns a short single-line preview of a plaintext string:
182186 * the first paragraph/heading line, truncated to maxLen chars.
183187 */
184-export function plaintextPreview(text: string, maxLen = PREVIEW_MAX_LENGTH): string {
188+export function plaintextPreview(
189+ text: string,
190+ maxLen = PREVIEW_MAX_LENGTH,
191+): string {
185192 const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
186193 const firstLine = firstBlock.split("\n")[0] ?? "";
187194 if (firstLine.length <= maxLen) return firstLine;
188195 const truncated = firstLine.slice(0, maxLen);
189196 const lastSpace = truncated.lastIndexOf(" ");
190197 return (
191- (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD ? truncated.slice(0, lastSpace) : truncated) +
192- "…"
198+ (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
199+ ? truncated.slice(0, lastSpace)
200+ : truncated) + "…"
193201 );
194202 }
Msrc/styles/components.css
@@ -1927,4 +1927,216 @@
19271927 align-items: center;
19281928 gap: var(--space-2);
19291929 }
1930+
1931+ /* --- CI / Pipelines --- */
1932+ .ci-status-pill {
1933+ display: inline-block;
1934+ padding: 2px 8px;
1935+ border-radius: 12px;
1936+ font-size: var(--text-xs);
1937+ font-weight: 600;
1938+ text-transform: uppercase;
1939+ letter-spacing: 0.03em;
1940+ }
1941+ .ci-status-pending {
1942+ background: var(--color-border);
1943+ color: var(--color-text-muted);
1944+ }
1945+ .ci-status-running {
1946+ background: #0b5cab;
1947+ color: #fff;
1948+ }
1949+ .ci-status-success {
1950+ background: #1a7f37;
1951+ color: #fff;
1952+ }
1953+ .ci-status-failure {
1954+ background: #cf222e;
1955+ color: #fff;
1956+ }
1957+ .ci-status-cancelled {
1958+ background: var(--color-border);
1959+ color: var(--color-text-muted);
1960+ }
1961+ .ci-status-skipped {
1962+ background: var(--color-border);
1963+ color: var(--color-text-muted);
1964+ }
1965+
1966+ .ci-run-id {
1967+ font-weight: 600;
1968+ margin-left: var(--space-2);
1969+ }
1970+ .ci-sha {
1971+ font-family: var(--font-mono);
1972+ font-size: var(--text-xs);
1973+ background: var(--color-code-bg);
1974+ padding: 1px 4px;
1975+ border-radius: 3px;
1976+ }
1977+ .ci-run-actions {
1978+ display: flex;
1979+ align-items: center;
1980+ gap: var(--space-2);
1981+ }
1982+ .ci-steps {
1983+ margin-top: var(--space-4);
1984+ }
1985+ .ci-step {
1986+ border: 1px solid var(--color-border);
1987+ border-radius: 6px;
1988+ margin-bottom: var(--space-2);
1989+ overflow: hidden;
1990+ }
1991+ .ci-step-summary {
1992+ display: flex;
1993+ align-items: center;
1994+ gap: var(--space-2);
1995+ padding: var(--space-3);
1996+ cursor: pointer;
1997+ list-style: none;
1998+ background: var(--color-bg-secondary);
1999+ }
2000+ .ci-step-summary::-webkit-details-marker {
2001+ display: none;
2002+ }
2003+ .ci-step-name {
2004+ font-weight: 600;
2005+ flex: 1;
2006+ }
2007+ .ci-step-duration {
2008+ font-size: var(--text-xs);
2009+ }
2010+ .ci-step-log {
2011+ margin: 0;
2012+ padding: var(--space-3);
2013+ font-family: var(--font-mono);
2014+ font-size: var(--text-xs);
2015+ background: var(--color-code-bg);
2016+ overflow-x: auto;
2017+ white-space: pre-wrap;
2018+ word-break: break-all;
2019+ max-height: 500px;
2020+ overflow-y: auto;
2021+ }
2022+ .ci-step-running-indicator {
2023+ padding: var(--space-3);
2024+ font-size: var(--text-sm);
2025+ }
2026+ .ci-step-pending {
2027+ padding: var(--space-3);
2028+ color: var(--color-text-muted);
2029+ }
2030+ .ci-artifacts {
2031+ margin-top: var(--space-4);
2032+ }
2033+ .ci-artifact-list {
2034+ list-style: none;
2035+ margin: 0;
2036+ padding: 0;
2037+ }
2038+ .ci-artifact-item {
2039+ display: flex;
2040+ align-items: center;
2041+ justify-content: space-between;
2042+ padding: var(--space-2) 0;
2043+ border-bottom: 1px solid var(--color-border);
2044+ }
2045+ .ci-artifact-item:last-child {
2046+ border-bottom: none;
2047+ }
2048+ .ci-artifact-name {
2049+ font-weight: 500;
2050+ }
2051+ .ci-artifact-size {
2052+ font-size: var(--text-xs);
2053+ font-family: var(--font-mono);
2054+ }
2055+ .ci-overrides {
2056+ margin-top: var(--space-4);
2057+ }
2058+ .ci-vars-list {
2059+ display: grid;
2060+ grid-template-columns: max-content 1fr;
2061+ gap: var(--space-1) var(--space-4);
2062+ margin: 0;
2063+ }
2064+ .ci-vars-list dt {
2065+ color: var(--color-text-muted);
2066+ }
2067+ .ci-vars-list dd {
2068+ margin: 0;
2069+ }
2070+ .ci-help {
2071+ margin-top: var(--space-4);
2072+ padding: var(--space-3) var(--space-4);
2073+ }
2074+ .ci-help-summary {
2075+ cursor: pointer;
2076+ font-weight: 600;
2077+ font-size: var(--text-sm);
2078+ padding: var(--space-1) var(--space-2);
2079+ list-style: none;
2080+ display: flex;
2081+ align-items: center;
2082+ justify-content: space-between;
2083+ gap: var(--space-3);
2084+ }
2085+ .ci-help-summary::-webkit-details-marker {
2086+ display: none;
2087+ }
2088+ .ci-help-download {
2089+ flex-shrink: 0;
2090+ }
2091+ .ci-help-body {
2092+ padding: var(--space-2);
2093+ border-top: 1px solid var(--color-border);
2094+ margin-top: 0.5rem;
2095+ }
2096+ .ci-help-desc {
2097+ margin: var(--space-1) 0 var(--space-2);
2098+ font-size: var(--text-sm);
2099+ color: var(--color-text-muted);
2100+ }
2101+ .ci-help-sections {
2102+ display: grid;
2103+ grid-template-columns: 1fr 1fr;
2104+ gap: var(--space-3);
2105+ }
2106+ .ci-help-section-title {
2107+ font-size: var(--text-xs);
2108+ font-weight: 700;
2109+ text-transform: uppercase;
2110+ letter-spacing: 0.05em;
2111+ color: var(--color-text-muted);
2112+ margin: 0 0 var(--space-2);
2113+ }
2114+ .ci-help-vars {
2115+ display: grid;
2116+ grid-template-columns: max-content 1fr;
2117+ gap: 2px var(--space-3);
2118+ margin: 0;
2119+ font-size: var(--text-xs);
2120+ }
2121+ .ci-help-vars dt { margin: 0; }
2122+ .ci-help-vars dd {
2123+ margin: 0;
2124+ color: var(--color-text-muted);
2125+ align-self: center;
2126+ }
2127+ .ci-help-badge-desc {
2128+ font-size: var(--text-xs);
2129+ color: var(--color-text-muted);
2130+ margin: 0 0 var(--space-1);
2131+ }
2132+ .ci-help-badge-code {
2133+ display: block;
2134+ font-size: var(--text-xs);
2135+ background: var(--color-code-bg);
2136+ padding: var(--space-1) var(--space-2);
2137+ border-radius: 3px;
2138+ overflow-x: auto;
2139+ white-space: nowrap;
2140+ user-select: all;
2141+ }
19302142 }
Asrc/views/ci/CiHistory.tsx
@@ -0,0 +1,243 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import { formatDateTime } from "../../lib/formatDate.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import { Layout } from "../layout.tsx";
5+import { Pagination, type PaginationInfo } from "../Pagination.tsx";
6+import { RepoHeader } from "../repos/RepoHeader.tsx";
7+import { RepoNav } from "../repos/RepoNav.tsx";
8+import { CiStatusPill } from "./CiStatusPill.tsx";
9+
10+interface RunSummary {
11+ id: number;
12+ status: string;
13+ trigger_source: string;
14+ commit_sha: string | null;
15+ commit_branch: string | null;
16+ commit_tag: string | null;
17+ started_at: string | null;
18+ finished_at: string | null;
19+ created_at: string;
20+ triggered_by_username: string | null;
21+ artifact_count: number;
22+}
23+
24+interface CiHistoryProps {
25+ user: SessionUser | null;
26+ repo: RepositoryRow;
27+ runs: RunSummary[];
28+ pagination: PaginationInfo;
29+ manualTriggerDisabledReason: string | null;
30+}
31+
32+function duration(start: string | null, end: string | null): string {
33+ if (!start || !end) return "";
34+ const ms = new Date(end).getTime() - new Date(start).getTime();
35+ if (ms < 0) return "";
36+ const s = Math.floor(ms / 1000);
37+ if (s < 60) return `${s}s`;
38+ const m = Math.floor(s / 60);
39+ const rem = s % 60;
40+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
41+}
42+
43+const CI_VARIABLES = [
44+ ["CI", "always true"],
45+ ["CI_PIPELINE_ID", "numeric run ID"],
46+ ["CI_COMMIT_SHA", "full commit hash"],
47+ ["CI_COMMIT_SHORT_SHA", "first 8 chars"],
48+ ["CI_COMMIT_BRANCH", "branch name (empty for tags)"],
49+ ["CI_COMMIT_TAG", "tag name (empty for branches)"],
50+ ["CI_COMMIT_REF_NAME", "branch or tag name"],
51+ ["CI_TRIGGER_SOURCE", "push · tag · manual"],
52+ ["CI_REPO_NAME", "repository name"],
53+ ["CI_SERVER_URL", "HearthForge base URL"],
54+];
55+
56+function CiHelp({ repo }: { repo: RepositoryRow }) {
57+ return (
58+ <details class="form-card ci-help">
59+ <summary class="ci-help-summary">
60+ <span>How to define a pipeline file</span>
61+ <a
62+ href="/assets/hearthforge-ci-template.toml"
63+ download=".hearthforge-ci.toml"
64+ class="btn btn-sm btn-secondary ci-help-download"
65+ >
66+ Download template
67+ </a>
68+ </summary>
69+ <div class="ci-help-body">
70+ <p class="ci-help-desc">
71+ Add <code>.hearthforge-ci.toml</code> to your repository
72+ root. Each <code>[section]</code> is a step executed in
73+ file order. Reserved tables:{" "}
74+ <code>[on]</code> (triggers) and{" "}
75+ <code>[variables]</code> (user-overridable inputs).
76+ </p>
77+ <div class="ci-help-sections">
78+ <div class="ci-help-section">
79+ <h4 class="ci-help-section-title">
80+ Predefined variables
81+ </h4>
82+ <dl class="ci-help-vars">
83+ {CI_VARIABLES.map(([name, desc]) => (
84+ <>
85+ <dt>
86+ <code>{name}</code>
87+ </dt>
88+ <dd>{desc}</dd>
89+ </>
90+ ))}
91+ </dl>
92+ </div>
93+ <div class="ci-help-section">
94+ <h4 class="ci-help-section-title">Status badge</h4>
95+ <p class="ci-help-badge-desc">
96+ Embed in your README:
97+ </p>
98+ <code class="ci-help-badge-code">{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
99+ <h4 class="ci-help-section-title" style="margin-top: var(--space-4)">
100+ Artifact types
101+ </h4>
102+ <dl class="ci-help-vars">
103+ {[
104+ ["publish_file", "copy file as-is"],
105+ ["publish_tar", ".tar archive"],
106+ ["publish_gzip", ".tar.gz archive"],
107+ ["publish_zstd", ".tar.zst archive"],
108+ ["publish_zip", ".zip archive"],
109+ ].map(([k, v]) => (
110+ <>
111+ <dt>
112+ <code>{k}</code>
113+ </dt>
114+ <dd>{v}</dd>
115+ </>
116+ ))}
117+ </dl>
118+ </div>
119+ </div>
120+ </div>
121+ </details>
122+ );
123+}
124+
125+export function CiHistory({ user, repo, runs, pagination, manualTriggerDisabledReason }: CiHistoryProps) {
126+ const isRunning = runs.some(
127+ (r) => r.status === "pending" || r.status === "running",
128+ );
129+ return (
130+ <Layout user={user} title={`Pipelines — ${repo.name}`}>
131+ {
132+ (isRunning ? (
133+ <meta http-equiv="refresh" content="4" />
134+ ) : (
135+ ""
136+ )) as unknown as JSX.Element
137+ }
138+ <div class="container">
139+ <RepoHeader repo={repo} />
140+ <RepoNav repo={repo} active="ci" user={user} />
141+ <div class="list-header">
142+ <h2 class="list-heading">Pipelines</h2>
143+ {user?.isAdmin && (
144+ <form
145+ method="POST"
146+ action={`/${repo.name}/ci/run`}
147+ class="inline-form"
148+ >
149+ <button
150+ type="submit"
151+ class="btn btn-primary btn-sm"
152+ disabled={manualTriggerDisabledReason ? true : undefined}
153+ title={manualTriggerDisabledReason ?? undefined}
154+ >
155+ Run pipeline
156+ </button>
157+ </form>
158+ )}
159+ </div>
160+ {runs.length === 0 ? (
161+ <div class="empty-state">
162+ <p>No pipeline runs yet.</p>
163+ <p class="text-muted">
164+ Push a <code>.hearthforge-ci.toml</code> to your
165+ repository to get started.
166+ </p>
167+ </div>
168+ ) : (
169+ <ul class="issue-list">
170+ {runs.map((run) => (
171+ <li class="issue-item">
172+ <div class="release-item-header">
173+ <div class="release-item-main">
174+ <a
175+ href={`/${repo.name}/ci/${run.id}`}
176+ class="release-item-title"
177+ >
178+ <CiStatusPill status={run.status} />
179+ <span class="ci-run-id">
180+ #{run.id}
181+ </span>
182+ </a>
183+ <div class="release-item-meta">
184+ {run.commit_sha && (
185+ <code class="ci-sha">
186+ {run.commit_sha.slice(0, 8)}
187+ </code>
188+ )}
189+ {run.commit_branch && (
190+ <span class="badge">
191+ {run.commit_branch}
192+ </span>
193+ )}
194+ {run.commit_tag && (
195+ <span class="badge">
196+ {run.commit_tag}
197+ </span>
198+ )}
199+ <span class="text-muted">
200+ {run.trigger_source}
201+ </span>
202+ {run.triggered_by_username && (
203+ <span class="text-muted">
204+ by{" "}
205+ {run.triggered_by_username}
206+ </span>
207+ )}
208+ {run.artifact_count > 0 && (
209+ <span>
210+ {run.artifact_count}{" "}
211+ artifact
212+ {run.artifact_count !== 1
213+ ? "s"
214+ : ""}
215+ </span>
216+ )}
217+ {run.started_at &&
218+ run.finished_at && (
219+ <span class="text-muted">
220+ {duration(
221+ run.started_at,
222+ run.finished_at,
223+ )}
224+ </span>
225+ )}
226+ </div>
227+ </div>
228+ <div class="release-item-date">
229+ <time datetime={run.created_at}>
230+ {formatDateTime(run.created_at)}
231+ </time>
232+ </div>
233+ </div>
234+ </li>
235+ ))}
236+ </ul>
237+ )}
238+ <Pagination {...pagination} />
239+ <CiHelp repo={repo} />
240+ </div>
241+ </Layout>
242+ );
243+}
Asrc/views/ci/CiRunDetail.tsx
@@ -0,0 +1,234 @@
1+import type {
2+ CiArtifactRow,
3+ CiStepRow,
4+ RepositoryRow,
5+} from "../../db/index.ts";
6+import { formatDateTime } from "../../lib/formatDate.ts";
7+import type { SessionUser } from "../../middleware/session.ts";
8+import { Layout } from "../layout.tsx";
9+import { RepoHeader } from "../repos/RepoHeader.tsx";
10+import { RepoNav } from "../repos/RepoNav.tsx";
11+import { CiStatusPill } from "./CiStatusPill.tsx";
12+
13+interface RunDetail {
14+ id: number;
15+ status: string;
16+ trigger_source: string;
17+ commit_sha: string | null;
18+ commit_branch: string | null;
19+ commit_tag: string | null;
20+ variable_overrides: string | null;
21+ started_at: string | null;
22+ finished_at: string | null;
23+ created_at: string;
24+ triggered_by_username: string | null;
25+}
26+
27+interface CiRunDetailProps {
28+ user: SessionUser | null;
29+ repo: RepositoryRow;
30+ run: RunDetail;
31+ steps: CiStepRow[];
32+ artifacts: CiArtifactRow[];
33+}
34+
35+function duration(start: string | null, end: string | null): string {
36+ if (!start || !end) return "";
37+ const ms = new Date(end).getTime() - new Date(start).getTime();
38+ if (ms < 0) return "";
39+ const s = Math.floor(ms / 1000);
40+ if (s < 60) return `${s}s`;
41+ const m = Math.floor(s / 60);
42+ const rem = s % 60;
43+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
44+}
45+
46+function formatBytes(bytes: number): string {
47+ if (bytes < 1024) return `${bytes} B`;
48+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
49+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
50+}
51+
52+export function CiRunDetail({
53+ user,
54+ repo,
55+ run,
56+ steps,
57+ artifacts,
58+}: CiRunDetailProps) {
59+ const isActive = run.status === "pending" || run.status === "running";
60+
61+ const variableOverrides: Record<string, string> = run.variable_overrides
62+ ? JSON.parse(run.variable_overrides)
63+ : {};
64+ const hasOverrides = Object.keys(variableOverrides).length > 0;
65+
66+ return (
67+ <Layout user={user} title={`Pipeline #${run.id} — ${repo.name}`}>
68+ {
69+ (isActive ? (
70+ <meta http-equiv="refresh" content="3" />
71+ ) : (
72+ ""
73+ )) as unknown as JSX.Element
74+ }
75+ <div class="container">
76+ <RepoHeader repo={repo} />
77+ <RepoNav repo={repo} active="ci" user={user} />
78+
79+ <div class="release-detail-header">
80+ <div>
81+ <h2 class="release-detail-title">
82+ <CiStatusPill status={run.status} /> Pipeline #
83+ {run.id}
84+ </h2>
85+ <div class="release-item-meta">
86+ {run.commit_sha && (
87+ <code class="ci-sha">
88+ {run.commit_sha.slice(0, 8)}
89+ </code>
90+ )}
91+ {run.commit_branch && (
92+ <a
93+ href={`/${repo.name}/tree/${run.commit_branch}`}
94+ class="badge"
95+ >
96+ {run.commit_branch}
97+ </a>
98+ )}
99+ {run.commit_tag && (
100+ <a
101+ href={`/${repo.name}/tree/${run.commit_tag}`}
102+ class="badge"
103+ >
104+ {run.commit_tag}
105+ </a>
106+ )}
107+ <span class="text-muted">
108+ triggered by {run.trigger_source}
109+ {run.triggered_by_username &&
110+ ` (${run.triggered_by_username})`}
111+ </span>
112+ {run.started_at && run.finished_at && (
113+ <span class="text-muted">
114+ {duration(run.started_at, run.finished_at)}
115+ </span>
116+ )}
117+ <time datetime={run.created_at} class="text-muted">
118+ {formatDateTime(run.created_at)}
119+ </time>
120+ </div>
121+ </div>
122+ {user?.isAdmin && (
123+ <div class="ci-run-actions">
124+ {isActive ? (
125+ <form
126+ method="POST"
127+ action={`/${repo.name}/ci/${run.id}/cancel`}
128+ class="inline-form"
129+ >
130+ <button
131+ type="submit"
132+ class="btn btn-danger btn-sm"
133+ >
134+ Cancel
135+ </button>
136+ </form>
137+ ) : (
138+ <form
139+ method="POST"
140+ action={`/${repo.name}/ci/${run.id}/retry`}
141+ class="inline-form"
142+ >
143+ <button
144+ type="submit"
145+ class="btn btn-secondary btn-sm"
146+ >
147+ Retry
148+ </button>
149+ </form>
150+ )}
151+ </div>
152+ )}
153+ </div>
154+
155+ {hasOverrides && (
156+ <div class="form-card ci-overrides">
157+ <h3 class="section-title">Variable overrides</h3>
158+ <dl class="ci-vars-list">
159+ {Object.entries(variableOverrides).map(([k, v]) => (
160+ <>
161+ <dt>
162+ <code>{k}</code>
163+ </dt>
164+ <dd>{v}</dd>
165+ </>
166+ ))}
167+ </dl>
168+ </div>
169+ )}
170+
171+ <div class="ci-steps">
172+ <h3 class="section-title">Steps</h3>
173+ {steps.length === 0 ? (
174+ <div class="ci-step-pending">
175+ <span class="text-muted">Waiting to start…</span>
176+ </div>
177+ ) : (
178+ steps.map((step) => (
179+ <details
180+ class={`ci-step ci-step-${step.status}`}
181+ open={
182+ step.status === "failure" ? true : undefined
183+ }
184+ >
185+ <summary class="ci-step-summary">
186+ <CiStatusPill status={step.status} />
187+ <span class="ci-step-name">
188+ {step.name}
189+ </span>
190+ {step.started_at && step.finished_at && (
191+ <span class="ci-step-duration text-muted">
192+ {duration(
193+ step.started_at,
194+ step.finished_at,
195+ )}
196+ </span>
197+ )}
198+ </summary>
199+ {step.log ? (
200+ <pre class="ci-step-log">{step.log}</pre>
201+ ) : step.status === "running" ? (
202+ <div class="ci-step-running-indicator text-muted">
203+ Running…
204+ </div>
205+ ) : null}
206+ </details>
207+ ))
208+ )}
209+ </div>
210+
211+ {artifacts.length > 0 && (
212+ <div class="form-card ci-artifacts">
213+ <h3 class="section-title">Artifacts</h3>
214+ <ul class="ci-artifact-list">
215+ {artifacts.map((artifact) => (
216+ <li class="ci-artifact-item">
217+ <a
218+ href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
219+ class="ci-artifact-name"
220+ >
221+ {artifact.filename}
222+ </a>
223+ <span class="ci-artifact-size text-muted">
224+ {formatBytes(artifact.size)}
225+ </span>
226+ </li>
227+ ))}
228+ </ul>
229+ </div>
230+ )}
231+ </div>
232+ </Layout>
233+ );
234+}
Asrc/views/ci/CiStatusPill.tsx
@@ -0,0 +1,13 @@
1+const statusStyles: Record<string, string> = {
2+ pending: "ci-status-pending",
3+ running: "ci-status-running",
4+ success: "ci-status-success",
5+ failure: "ci-status-failure",
6+ cancelled: "ci-status-cancelled",
7+ skipped: "ci-status-skipped",
8+};
9+
10+export function CiStatusPill({ status }: { status: string }) {
11+ const cls = statusStyles[status] ?? "ci-status-pending";
12+ return <span class={`ci-status-pill ${cls}`}>{status}</span>;
13+}
Msrc/views/repos/BranchList.tsx
@@ -1,8 +1,8 @@
1+import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts";
12 import type { RepositoryRow } from "../../db/index.ts";
23 import { formatDateTime } from "../../lib/formatDate.ts";
34 import type { SessionUser } from "../../middleware/session.ts";
45 import type { BranchInfo } from "../../services/git.ts";
5-import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts";
66 import { Layout } from "../layout.tsx";
77 import { Pagination } from "../Pagination.tsx";
88 import { RepoHeader } from "./RepoHeader.tsx";
@@ -154,7 +154,9 @@ export function BranchList({
154154 required
155155 placeholder="new-name"
156156 value={b.name}
157- maxlength={MAX_BRANCH_NAME_LENGTH}
157+ maxlength={
158+ MAX_BRANCH_NAME_LENGTH
159+ }
158160 />
159161 <button
160162 type="submit"
Msrc/views/repos/FileEdit.tsx
@@ -1,6 +1,6 @@
1+import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
12 import type { RepositoryRow } from "../../db/index.ts";
23 import type { SessionUser } from "../../middleware/session.ts";
3-import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
44 import { Layout } from "../layout.tsx";
55 import { RepoHeader } from "../repos/RepoHeader.tsx";
66 import { RepoNav } from "./RepoNav.tsx";
Msrc/views/repos/NewFileForm.tsx
@@ -1,6 +1,6 @@
1+import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
12 import type { RepositoryRow } from "../../db/index.ts";
23 import type { SessionUser } from "../../middleware/session.ts";
3-import { MAX_FILE_PATH_LENGTH } from "../../constants.ts";
44 import { Layout } from "../layout.tsx";
55 import { RepoHeader } from "../repos/RepoHeader.tsx";
66 import { RepoNav } from "./RepoNav.tsx";
Msrc/views/repos/RepoNav.tsx
@@ -11,6 +11,7 @@ interface RepoNavProps {
1111 | "issues"
1212 | "patches"
1313 | "releases"
14+ | "ci"
1415 | "settings";
1516 user?: SessionUser | null;
1617 }
@@ -28,6 +29,7 @@ export function RepoNav({ repo, active, user }: RepoNavProps) {
2829 { key: "issues", label: "Issues", href: `/${repo.name}/issues` },
2930 { key: "patches", label: "Patches", href: `/${repo.name}/patches` },
3031 { key: "releases", label: "Releases", href: `/${repo.name}/releases` },
32+ { key: "ci", label: "Pipelines", href: `/${repo.name}/ci` },
3133 ] as const;
3234 return (
3335 <div class="repo-nav-bar">
Msrc/views/repos/RepoSettings.tsx
@@ -1,4 +1,12 @@
11 import type { LabelRow, RepositoryRow } from "../../db/index.ts";
2+
3+interface SecretSummary {
4+ id: number;
5+ name: string;
6+ description: string | null;
7+ created_at: string;
8+}
9+
210 import type { SessionUser } from "../../middleware/session.ts";
311 import { Layout } from "../layout.tsx";
412 import { RepoHeader } from "./RepoHeader.tsx";
@@ -9,6 +17,7 @@ interface RepoSettingsProps {
917 repo: RepositoryRow;
1018 branches: string[];
1119 labels: LabelRow[];
20+ secrets: SecretSummary[];
1221 success?: string;
1322 error?: string;
1423 }
@@ -18,6 +27,7 @@ export function RepoSettings({
1827 repo,
1928 branches,
2029 labels,
30+ secrets,
2131 success,
2232 error,
2333 }: RepoSettingsProps) {
@@ -205,6 +215,81 @@ export function RepoSettings({
205215 </button>
206216 </form>
207217 </div>
218+ <div class="form-card">
219+ <h2 class="section-title">CI Secrets</h2>
220+ <p
221+ class="text-muted"
222+ style="font-size: var(--text-sm); margin-bottom: var(--space-3)"
223+ >
224+ Secrets are injected as environment variables into
225+ pipeline runs and masked in logs. Values are write-only
226+ — they cannot be retrieved after saving.
227+ </p>
228+ {secrets.length > 0 && (
229+ <div class="label-settings-list">
230+ {secrets.map((secret) => (
231+ <div class="label-settings-item">
232+ <span class="label-settings-name">
233+ <code>{secret.name}</code>
234+ </span>
235+ {secret.description && (
236+ <span class="text-muted">
237+ {secret.description}
238+ </span>
239+ )}
240+ <span class="text-muted">●●●●●●</span>
241+ <form
242+ method="POST"
243+ action={`/${repo.name}/settings/ci-secrets/delete`}
244+ >
245+ <input
246+ type="hidden"
247+ name="id"
248+ value={String(secret.id)}
249+ />
250+ <button
251+ class="btn btn-danger btn-sm"
252+ type="submit"
253+ >
254+ Delete
255+ </button>
256+ </form>
257+ </div>
258+ ))}
259+ </div>
260+ )}
261+ <form
262+ method="POST"
263+ action={`/${repo.name}/settings/ci-secrets`}
264+ class="label-add-form"
265+ >
266+ <input
267+ class="form-input label-name-input"
268+ type="text"
269+ name="name"
270+ placeholder="SECRET_NAME"
271+ pattern="[A-Za-z_][A-Za-z0-9_]*"
272+ required
273+ />
274+ <input
275+ class="form-input label-name-input"
276+ type="password"
277+ name="value"
278+ placeholder="Value"
279+ autocomplete="new-password"
280+ required
281+ />
282+ <input
283+ class="form-input label-name-input"
284+ type="text"
285+ name="description"
286+ placeholder="Description (optional)"
287+ />
288+ <button type="submit" class="btn btn-secondary btn-sm">
289+ Save secret
290+ </button>
291+ </form>
292+ </div>
208293 <div class="danger-zone">
209294 <h2 class="section-title danger-title">Danger zone</h2>
210295 <div class="form-card danger-card">
Msrc/views/repos/TagList.tsx
@@ -1,8 +1,11 @@
1+import {
2+ MAX_TAG_MESSAGE_LENGTH,
3+ MAX_TAG_NAME_LENGTH,
4+} from "../../constants.ts";
15 import type { RepositoryRow } from "../../db/index.ts";
26 import { formatDateTime } from "../../lib/formatDate.ts";
37 import type { SessionUser } from "../../middleware/session.ts";
48 import type { TagInfo } from "../../services/git.ts";
5-import { MAX_TAG_MESSAGE_LENGTH, MAX_TAG_NAME_LENGTH } from "../../constants.ts";
69 import { Layout } from "../layout.tsx";
710 import { Pagination } from "../Pagination.tsx";
811 import { RepoHeader } from "./RepoHeader.tsx";
Atests/ci.unit.test.ts
@@ -0,0 +1,191 @@
1+import { describe, test, expect } from "bun:test";
2+import {
3+ parseCiConfig,
4+ shouldTriggerPush,
5+ shouldTriggerTag,
6+} from "../src/services/ci.ts";
7+
8+describe("parseCiConfig", () => {
9+ test("parses a minimal valid config", () => {
10+ const cfg = parseCiConfig(`
11+image = "debian:latest"
12+[build]
13+run_sh = "make all"
14+`);
15+ expect(cfg).not.toBeNull();
16+ expect(cfg!.image).toBe("debian:latest");
17+ expect(cfg!.steps).toHaveLength(1);
18+ expect(cfg!.steps[0]!.name).toBe("build");
19+ expect(cfg!.steps[0]!.run_sh).toBe("make all");
20+ });
21+
22+ test("returns null when image is missing", () => {
23+ expect(parseCiConfig(`[build]\nrun_sh = "make"`)).toBeNull();
24+ });
25+
26+ test("returns null on invalid TOML", () => {
27+ expect(parseCiConfig("image = [unclosed")).toBeNull();
28+ });
29+
30+ test("excludes reserved table names from steps", () => {
31+ const cfg = parseCiConfig(`
32+image = "alpine"
33+[on]
34+push = ["main"]
35+[variables]
36+ [variables.FOO]
37+ default = "bar"
38+[step1]
39+run_sh = "echo hi"
40+`);
41+ expect(cfg).not.toBeNull();
42+ expect(cfg!.steps.map((s) => s.name)).toEqual(["step1"]);
43+ });
44+
45+ test("preserves step order", () => {
46+ const cfg = parseCiConfig(`
47+image = "alpine"
48+[setup]
49+run_sh = "apt install"
50+[compile]
51+run_sh = "make"
52+[test]
53+run_sh = "./test.sh"
54+`);
55+ expect(cfg!.steps.map((s) => s.name)).toEqual([
56+ "setup",
57+ "compile",
58+ "test",
59+ ]);
60+ });
61+
62+ test("parses optional top-level fields", () => {
63+ const cfg = parseCiConfig(`
64+image = "alpine"
65+work_dir = "/ci"
66+clone_project_to = "/ci/repo"
67+shell_setup = "set -e"
68+timeout = 1800
69+cpu_limit = 2.0
70+memory_limit = "1g"
71+cache = ["/root/.npm"]
72+`);
73+ expect(cfg!.work_dir).toBe("/ci");
74+ expect(cfg!.clone_project_to).toBe("/ci/repo");
75+ expect(cfg!.shell_setup).toBe("set -e");
76+ expect(cfg!.timeout).toBe(1800);
77+ expect(cfg!.cpu_limit).toBe(2.0);
78+ expect(cfg!.memory_limit).toBe("1g");
79+ expect(cfg!.cache).toEqual(["/root/.npm"]);
80+ });
81+
82+ test("parses on.push as array", () => {
83+ const cfg = parseCiConfig(`
84+image = "alpine"
85+[on]
86+push = ["main", "develop"]
87+`);
88+ expect(cfg!.on?.push).toEqual(["main", "develop"]);
89+ });
90+
91+ test("parses on.tag and on.manual", () => {
92+ const cfg = parseCiConfig(`
93+image = "alpine"
94+[on]
95+tag = true
96+manual = true
97+`);
98+ expect(cfg!.on?.tag).toBe(true);
99+ expect(cfg!.on?.manual).toBe(true);
100+ });
101+
102+ test("parses variables with default and description", () => {
103+ const cfg = parseCiConfig(`
104+image = "alpine"
105+[variables]
106+ [variables.DEPLOY_ENV]
107+ default = "staging"
108+ description = "Target environment"
109+ [variables.VERSION]
110+ default = "1.0.0"
111+`);
112+ expect(cfg!.variables?.DEPLOY_ENV).toEqual({
113+ default: "staging",
114+ description: "Target environment",
115+ });
116+ expect(cfg!.variables?.VERSION?.default).toBe("1.0.0");
117+ });
118+
119+ test("parses step-level fields", () => {
120+ const cfg = parseCiConfig(`
121+image = "alpine"
122+[test]
123+run_sh = "./run_tests"
124+run_if = 'test -n "\${CI_COMMIT_TAG}"'
125+clear = true
126+timeout = 300
127+publish_file = ["/dist/binary"]
128+publish_gzip = ["/dist/"]
129+`);
130+ const step = cfg!.steps[0]!;
131+ expect(step.run_sh).toBe("./run_tests");
132+ expect(step.run_if).toBe('test -n "${CI_COMMIT_TAG}"');
133+ expect(step.clear).toBe(true);
134+ expect(step.timeout).toBe(300);
135+ expect(step.publish_file).toEqual(["/dist/binary"]);
136+ expect(step.publish_gzip).toEqual(["/dist/"]);
137+ });
138+});
139+
140+describe("shouldTriggerPush", () => {
141+ function cfg(push: string[] | boolean) {
142+ return parseCiConfig(
143+ `image="alpine"\n[on]\npush=${JSON.stringify(push)}`,
144+ )!;
145+ }
146+
147+ test("matches exact branch name", () => {
148+ expect(shouldTriggerPush(cfg(["main"]), "main")).toBe(true);
149+ expect(shouldTriggerPush(cfg(["main"]), "develop")).toBe(false);
150+ });
151+
152+ test("wildcard * matches any branch", () => {
153+ expect(shouldTriggerPush(cfg(["*"]), "anything")).toBe(true);
154+ expect(shouldTriggerPush(cfg(["*"]), "main")).toBe(true);
155+ });
156+
157+ test("matches one of multiple branches", () => {
158+ const c = cfg(["main", "release"]);
159+ expect(shouldTriggerPush(c, "main")).toBe(true);
160+ expect(shouldTriggerPush(c, "release")).toBe(true);
161+ expect(shouldTriggerPush(c, "feature/x")).toBe(false);
162+ });
163+
164+ test("returns false when on.push is absent", () => {
165+ const c = parseCiConfig('image="alpine"')!;
166+ expect(shouldTriggerPush(c, "main")).toBe(false);
167+ });
168+
169+ test("glob prefix matching", () => {
170+ const c = cfg(["release/*"]);
171+ expect(shouldTriggerPush(c, "release/1.0")).toBe(true);
172+ expect(shouldTriggerPush(c, "main")).toBe(false);
173+ });
174+});
175+
176+describe("shouldTriggerTag", () => {
177+ test("returns true when on.tag = true", () => {
178+ const c = parseCiConfig('image="alpine"\n[on]\ntag=true')!;
179+ expect(shouldTriggerTag(c)).toBe(true);
180+ });
181+
182+ test("returns false when on.tag = false", () => {
183+ const c = parseCiConfig('image="alpine"\n[on]\ntag=false')!;
184+ expect(shouldTriggerTag(c)).toBe(false);
185+ });
186+
187+ test("returns false when on.tag is absent", () => {
188+ const c = parseCiConfig('image="alpine"')!;
189+ expect(shouldTriggerTag(c)).toBe(false);
190+ });
191+});
Atests/e2e.ci.test.ts
@@ -0,0 +1,672 @@
1+/**
2+ * CI pipeline E2E tests.
3+ *
4+ * Uses a mock Docker API server (Bun.serve over a Unix socket) so no real
5+ * Docker/Podman installation is required. The mock handles every endpoint
6+ * the CI service calls and lets individual tests queue custom exec responses
7+ * (output + exit code) to simulate success, failure, and specific log output.
8+ */
9+import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
10+import { chromium, type Browser, type BrowserContext } from "playwright";
11+import { existsSync, rmSync, writeFileSync } from "node:fs";
12+import { spawnSync } from "node:child_process";
13+import path from "node:path";
14+import {
15+ BASE,
16+ ADMIN_PASS,
17+ DATA_DIR,
18+ setupTestEnv,
19+ spawnServer,
20+ killServer,
21+ seedRepo,
22+ login,
23+} from "./helpers.ts";
24+import { db } from "../src/db/index.ts";
25+import config from "../src/config.ts";
26+import {
27+ resetDockerSocket,
28+ triggerRun,
29+} from "../src/services/ci.ts";
30+import { paths } from "../src/constants.ts";
31+
32+// ── Mock Docker server ────────────────────────────────────────────────────────
33+
34+const SOCKET_PATH = `/tmp/test-docker-ci-${process.pid}.sock`;
35+
36+interface ExecResp {
37+ output: string;
38+ exitCode: number;
39+}
40+
41+// Per-exec-ID response map, populated when exec is created
42+const execMap = new Map<string, ExecResp>();
43+// Queue consumed in order when execs are created — allows tests to pre-program
44+// specific step responses
45+const execQueue: ExecResp[] = [];
46+let execCounter = 0;
47+
48+function queueExec(resp: ExecResp) {
49+ execQueue.push(resp);
50+}
51+
52+function resetMock() {
53+ execMap.clear();
54+ execQueue.length = 0;
55+ execCounter = 0;
56+}
57+
58+/** Build a Docker multiplexed stream frame from a string. */
59+function muxFrame(text: string, stream = 1): Uint8Array {
60+ const payload = Buffer.from(text, "utf-8");
61+ const hdr = Buffer.alloc(8);
62+ hdr[0] = stream;
63+ hdr.writeUInt32BE(payload.length, 4);
64+ return Buffer.concat([hdr, payload]);
65+}
66+
67+/** Build a minimal tar archive containing one file. */
68+function makeTar(filename: string, content: string): Uint8Array {
69+ const data = Buffer.from(content, "utf-8");
70+ const hdr = Buffer.alloc(512);
71+ hdr.write(path.basename(filename).slice(0, 100), 0, "ascii");
72+ hdr.write("0000644\0", 100, "ascii"); // mode
73+ hdr.write("0000000\0", 108, "ascii"); // uid
74+ hdr.write("0000000\0", 116, "ascii"); // gid
75+ hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
76+ hdr.write("00000000000\0", 136, "ascii"); // mtime
77+ hdr[156] = 0x30; // type flag: regular file
78+ // Checksum: fill with spaces, compute, write back
79+ hdr.fill(0x20, 148, 156);
80+ let sum = 0;
81+ for (let i = 0; i < 512; i++) sum += hdr[i]!;
82+ hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
83+ // Pad file content to 512-byte block
84+ const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512;
85+ const padded = Buffer.alloc(paddedLen);
86+ data.copy(padded);
87+ return Buffer.concat([hdr, padded]);
88+}
89+
90+let mockServer: ReturnType<typeof Bun.serve>;
91+
92+function startMockDocker() {
93+ rmSync(SOCKET_PATH, { force: true });
94+ mockServer = Bun.serve({
95+ unix: SOCKET_PATH,
96+ fetch(req: Request): Response {
97+ const p = new URL(req.url).pathname;
98+ const qs = new URL(req.url).searchParams;
99+
100+ // Health check
101+ if (req.method === "GET" && p === "/v1.47/info") {
102+ return Response.json({ ServerVersion: "mock" });
103+ }
104+ // Pull image (streaming, just needs to resolve)
105+ if (req.method === "POST" && p.startsWith("/v1.47/images/create")) {
106+ return new Response('{"status":"Pull complete"}\n');
107+ }
108+ // Create container
109+ if (req.method === "POST" && /\/containers\/create/.test(p)) {
110+ return Response.json({ Id: "mock-ctr-001" });
111+ }
112+ // Start container
113+ if (
114+ req.method === "POST" &&
115+ /\/containers\/[^/]+\/start$/.test(p)
116+ ) {
117+ return new Response(null, { status: 204 });
118+ }
119+ // Create exec — pop next queued response and assign to this exec ID
120+ if (
121+ req.method === "POST" &&
122+ /\/containers\/[^/]+\/exec$/.test(p)
123+ ) {
124+ execCounter++;
125+ const execId = `mock-exec-${execCounter}`;
126+ execMap.set(
127+ execId,
128+ execQueue.shift() ?? { output: "", exitCode: 0 },
129+ );
130+ return Response.json({ Id: execId });
131+ }
132+ // Start exec — return queued output as mux stream
133+ if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) {
134+ const id = p.match(/\/exec\/([^/]+)\/start/)![1]!;
135+ const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
136+ return new Response(
137+ resp.output ? muxFrame(resp.output) : new Uint8Array(0),
138+ );
139+ }
140+ // Inspect exec — return exit code
141+ if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) {
142+ const id = p.match(/\/exec\/([^/]+)\/json/)![1]!;
143+ const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
144+ return Response.json({ ExitCode: resp.exitCode });
145+ }
146+ // Archive (used by publish_file artifact collection)
147+ if (
148+ req.method === "GET" &&
149+ /\/containers\/[^/]+\/archive/.test(p)
150+ ) {
151+ const filePath = qs.get("path") ?? "file.txt";
152+ return new Response(
153+ makeTar(path.basename(filePath), "artifact-content-123"),
154+ { headers: { "Content-Type": "application/x-tar" } },
155+ );
156+ }
157+ // Delete container
158+ if (req.method === "DELETE" && /\/containers\//.test(p)) {
159+ return new Response(null, { status: 204 });
160+ }
161+ return new Response("Not found", { status: 404 });
162+ },
163+ });
164+}
165+
166+// ── Helpers ───────────────────────────────────────────────────────────────────
167+
168+/** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */
169+function seedCiToml(repoName: string, toml: string): string {
170+ const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`);
171+ const tmp = `/tmp/hf-ci-seed-${Date.now()}`;
172+ try {
173+ spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" });
174+ spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], {
175+ stdio: "ignore",
176+ });
177+ spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], {
178+ stdio: "ignore",
179+ });
180+ writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml);
181+ spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], {
182+ stdio: "ignore",
183+ });
184+ spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], {
185+ stdio: "ignore",
186+ });
187+ spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], {
188+ stdio: "ignore",
189+ });
190+ const r = spawnSync(
191+ "git",
192+ ["-C", tmp, "rev-parse", "HEAD"],
193+ { stdio: ["ignore", "pipe", "ignore"] },
194+ );
195+ return r.stdout.toString().trim();
196+ } finally {
197+ rmSync(tmp, { recursive: true, force: true });
198+ }
199+}
200+
201+/** Poll until a CI run leaves pending/running state, then return its status. */
202+async function waitForRun(runId: number, timeoutMs = 10_000): Promise<string> {
203+ const deadline = Date.now() + timeoutMs;
204+ while (Date.now() < deadline) {
205+ const row = await db
206+ .selectFrom("ci_runs")
207+ .select("status")
208+ .where("id", "=", runId)
209+ .executeTakeFirst();
210+ if (row && row.status !== "pending" && row.status !== "running") {
211+ return row.status;
212+ }
213+ await Bun.sleep(100);
214+ }
215+ throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
216+}
217+
218+async function loggedInContext(
219+ browser: Browser,
220+ username = "admin",
221+ password = ADMIN_PASS,
222+): Promise<BrowserContext> {
223+ const ctx = await browser.newContext();
224+ const page = await ctx.newPage();
225+ await login(page, username, password);
226+ await page.close();
227+ return ctx;
228+}
229+
230+// ── Test setup ────────────────────────────────────────────────────────────────
231+
232+let browser: Browser;
233+let server: Awaited<ReturnType<typeof spawnServer>>;
234+let adminCtx: BrowserContext;
235+let adminUserId: number;
236+let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml
237+
238+const SIMPLE_TOML = `
239+image = "debian:latest"
240+
241+[on]
242+manual = true
243+push = ["main"]
244+
245+[hello]
246+run_sh = "echo hello"
247+`;
248+
249+const ARTIFACT_TOML = `
250+image = "debian:latest"
251+work_dir = "/ci"
252+
253+[on]
254+manual = true
255+
256+[build]
257+run_sh = "echo building"
258+publish_file = ["/ci/output.txt"]
259+`;
260+
261+beforeAll(async () => {
262+ await setupTestEnv();
263+
264+ // Point CI service at mock socket BEFORE starting any runs
265+ config.CI_DOCKER_SOCKET = SOCKET_PATH;
266+ resetDockerSocket();
267+ startMockDocker();
268+
269+ server = await spawnServer();
270+ browser = await chromium.launch();
271+ adminCtx = await loggedInContext(browser);
272+
273+ // Get admin user ID
274+ const row = await db
275+ .selectFrom("users")
276+ .select("id")
277+ .where("username", "=", "admin")
278+ .executeTakeFirst();
279+ adminUserId = row!.id;
280+
281+ // Create ci-repo via UI and seed it
282+ const page = await adminCtx.newPage();
283+ try {
284+ await page.goto(`${BASE}/new`);
285+ await page.fill("[name=name]", "ci-repo");
286+ await page.click('form[action="/new"] button[type=submit]');
287+ await page.waitForURL(`${BASE}/ci-repo`);
288+ } finally {
289+ await page.close();
290+ }
291+ seedRepo("ci-repo");
292+ ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
293+});
294+
295+afterAll(async () => {
296+ await adminCtx.close();
297+ await browser.close();
298+ await killServer(server);
299+ mockServer.stop(true);
300+ rmSync(SOCKET_PATH, { force: true });
301+});
302+
303+beforeEach(() => {
304+ resetMock();
305+});
306+
307+// ── Tests ─────────────────────────────────────────────────────────────────────
308+
309+describe("pipelines tab", () => {
310+ test("tab is visible in repo nav", async () => {
311+ const page = await adminCtx.newPage();
312+ try {
313+ await page.goto(`${BASE}/ci-repo`);
314+ const tab = page.locator('.repo-tab', { hasText: 'Pipelines' });
315+ expect(await tab.isVisible()).toBe(true);
316+ } finally {
317+ await page.close();
318+ }
319+ });
320+
321+ test("history page shows empty state when no runs", async () => {
322+ // Use a separate repo that has never had a run
323+ const page = await adminCtx.newPage();
324+ try {
325+ await page.goto(`${BASE}/ci-repo/ci`);
326+ expect(await page.locator(".empty-state").isVisible()).toBe(true);
327+ expect(await page.locator(".empty-state").textContent()).toContain(
328+ "No pipeline runs yet",
329+ );
330+ } finally {
331+ await page.close();
332+ }
333+ });
334+
335+ test("help section is collapsible and contains template download", async () => {
336+ const page = await adminCtx.newPage();
337+ try {
338+ await page.goto(`${BASE}/ci-repo/ci`);
339+ const help = page.locator("details.ci-help");
340+ expect(await help.isVisible()).toBe(true);
341+ await help.locator("summary").click();
342+ const dlLink = page.locator('a[download=".hearthforge-ci.toml"]');
343+ expect(await dlLink.isVisible()).toBe(true);
344+ } finally {
345+ await page.close();
346+ }
347+ });
348+});
349+
350+describe("successful run", () => {
351+ let runId: number;
352+
353+ beforeAll(async () => {
354+ queueExec({ output: "hello from mock CI\n", exitCode: 0 });
355+ runId = await triggerRun("ci-repo", {
356+ triggerSource: "manual",
357+ commitSha: ciRepoSha,
358+ commitBranch: "main",
359+ triggeredBy: adminUserId,
360+ });
361+ await waitForRun(runId);
362+ });
363+
364+ test("run status is success", async () => {
365+ const run = await db
366+ .selectFrom("ci_runs")
367+ .select("status")
368+ .where("id", "=", runId)
369+ .executeTakeFirst();
370+ expect(run?.status).toBe("success");
371+ });
372+
373+ test("step status is success and log is captured", async () => {
374+ const step = await db
375+ .selectFrom("ci_steps")
376+ .select(["status", "log"])
377+ .where("run_id", "=", runId)
378+ .where("name", "=", "hello")
379+ .executeTakeFirst();
380+ expect(step?.status).toBe("success");
381+ expect(step?.log).toContain("hello from mock CI");
382+ });
383+
384+ test("history page shows the completed run", async () => {
385+ const page = await adminCtx.newPage();
386+ try {
387+ await page.goto(`${BASE}/ci-repo/ci`);
388+ expect(
389+ await page.locator(".ci-status-pill.ci-status-success").count(),
390+ ).toBeGreaterThan(0);
391+ } finally {
392+ await page.close();
393+ }
394+ });
395+
396+ test("run detail page shows step and log", async () => {
397+ const page = await adminCtx.newPage();
398+ try {
399+ await page.goto(`${BASE}/ci-repo/ci/${runId}`);
400+ expect(
401+ await page.locator(".ci-step").first().textContent(),
402+ ).toContain("hello");
403+ // Open step details to see log
404+ await page.locator(".ci-step").first().click();
405+ expect(await page.locator(".ci-step-log").textContent()).toContain(
406+ "hello from mock CI",
407+ );
408+ } finally {
409+ await page.close();
410+ }
411+ });
412+
413+ test("retry creates a new run", async () => {
414+ const page = await adminCtx.newPage();
415+ try {
416+ await page.goto(`${BASE}/ci-repo/ci/${runId}`);
417+ await page.click('button:text("Retry")');
418+ // Should redirect to the new run
419+ await page.waitForURL(/\/ci-repo\/ci\/\d+/);
420+ const newRunId = Number(
421+ page.url().split("/ci/")[1],
422+ );
423+ expect(newRunId).toBeGreaterThan(runId);
424+ // Wait for new run to complete (uses default exit 0)
425+ const status = await waitForRun(newRunId);
426+ expect(status).toBe("success");
427+ } finally {
428+ await page.close();
429+ }
430+ });
431+});
432+
433+describe("failing run", () => {
434+ let runId: number;
435+
436+ beforeAll(async () => {
437+ // Step exec: non-zero exit code
438+ queueExec({ output: "build error: file not found\n", exitCode: 1 });
439+ runId = await triggerRun("ci-repo", {
440+ triggerSource: "manual",
441+ commitSha: ciRepoSha,
442+ commitBranch: "main",
443+ triggeredBy: adminUserId,
444+ });
445+ await waitForRun(runId);
446+ });
447+
448+ test("run status is failure", async () => {
449+ const run = await db
450+ .selectFrom("ci_runs")
451+ .select("status")
452+ .where("id", "=", runId)
453+ .executeTakeFirst();
454+ expect(run?.status).toBe("failure");
455+ });
456+
457+ test("step status is failure and error log captured", async () => {
458+ const step = await db
459+ .selectFrom("ci_steps")
460+ .select(["status", "log"])
461+ .where("run_id", "=", runId)
462+ .where("name", "=", "hello")
463+ .executeTakeFirst();
464+ expect(step?.status).toBe("failure");
465+ expect(step?.log).toContain("build error");
466+ });
467+
468+ test("run detail page shows failure status", async () => {
469+ const page = await adminCtx.newPage();
470+ try {
471+ await page.goto(`${BASE}/ci-repo/ci/${runId}`);
472+ expect(
473+ await page.locator(".ci-status-pill.ci-status-failure").count(),
474+ ).toBeGreaterThan(0);
475+ } finally {
476+ await page.close();
477+ }
478+ });
479+});
480+
481+describe("cancel", () => {
482+ test("cancelling a pending run marks it cancelled", async () => {
483+ // Trigger without queuing — run will start and eventually succeed,
484+ // but we cancel immediately before it gets far
485+ const runId = await triggerRun("ci-repo", {
486+ triggerSource: "manual",
487+ commitSha: ciRepoSha,
488+ commitBranch: "main",
489+ triggeredBy: adminUserId,
490+ });
491+ // Cancel via API before it completes
492+ const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, {
493+ method: "POST",
494+ redirect: "manual",
495+ });
496+ expect(resp.status).toBe(302);
497+
498+ // Wait and check final status
499+ const status = await waitForRun(runId);
500+ expect(["cancelled", "success", "failure"]).toContain(status);
501+
502+ // If we got there first, it's cancelled
503+ if (status === "cancelled") {
504+ const run = await db
505+ .selectFrom("ci_runs")
506+ .select("status")
507+ .where("id", "=", runId)
508+ .executeTakeFirst();
509+ expect(run?.status).toBe("cancelled");
510+ }
511+ });
512+});
513+
514+describe("artifacts", () => {
515+ let runId: number;
516+ let artifactId: number;
517+
518+ beforeAll(async () => {
519+ // Seed repo with artifact TOML
520+ const sha = seedCiToml("ci-repo", ARTIFACT_TOML);
521+ // work_dir causes 1 mkdir exec before the step
522+ // defaults: {output:'', exitCode:0} for both
523+ runId = await triggerRun("ci-repo", {
524+ triggerSource: "manual",
525+ commitSha: sha,
526+ commitBranch: "main",
527+ triggeredBy: adminUserId,
528+ });
529+ await waitForRun(runId);
530+
531+ const artifact = await db
532+ .selectFrom("ci_artifacts")
533+ .select("id")
534+ .where("run_id", "=", runId)
535+ .executeTakeFirst();
536+ artifactId = artifact?.id ?? 0;
537+ });
538+
539+ test("artifact row created in DB", async () => {
540+ const artifacts = await db
541+ .selectFrom("ci_artifacts")
542+ .selectAll()
543+ .where("run_id", "=", runId)
544+ .execute();
545+ expect(artifacts.length).toBe(1);
546+ expect(artifacts[0]!.filename).toBe("output.txt");
547+ });
548+
549+ test("artifact is downloadable via HTTP", async () => {
550+ expect(artifactId).toBeGreaterThan(0);
551+ const resp = await fetch(
552+ `${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`,
553+ );
554+ expect(resp.status).toBe(200);
555+ const body = await resp.text();
556+ expect(body).toBe("artifact-content-123");
557+ });
558+
559+ test("run detail page shows artifact list", async () => {
560+ const page = await adminCtx.newPage();
561+ try {
562+ await page.goto(`${BASE}/ci-repo/ci/${runId}`);
563+ expect(
564+ await page.locator(".ci-artifact-item").count(),
565+ ).toBeGreaterThan(0);
566+ expect(
567+ await page.locator(".ci-artifact-name").textContent(),
568+ ).toContain("output.txt");
569+ } finally {
570+ await page.close();
571+ }
572+ });
573+});
574+
575+describe("badge", () => {
576+ test("badge SVG returns success status after successful run", async () => {
577+ const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`);
578+ expect(resp.status).toBe(200);
579+ expect(resp.headers.get("Content-Type")).toContain("image/svg+xml");
580+ const body = await resp.text();
581+ expect(body).toContain("<svg");
582+ expect(body).toContain("success");
583+ });
584+
585+ test("badge returns 404 for private repo when not logged in", async () => {
586+ // Create a private repo
587+ const page = await adminCtx.newPage();
588+ try {
589+ await page.goto(`${BASE}/new`);
590+ await page.fill("[name=name]", "private-ci-repo");
591+ await page.check("[name=is_private]");
592+ await page.click('form[action="/new"] button[type=submit]');
593+ await page.waitForURL(`${BASE}/private-ci-repo`);
594+ } finally {
595+ await page.close();
596+ }
597+ const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`);
598+ expect(resp.status).toBe(404);
599+ });
600+});
601+
602+describe("secrets", () => {
603+ test("can add, list, and delete a secret via settings", async () => {
604+ const page = await adminCtx.newPage();
605+ try {
606+ await page.goto(`${BASE}/ci-repo/settings`);
607+ // Add secret — scope to the CI secrets form
608+ const secretsForm = page.locator('form[action$="/settings/ci-secrets"]');
609+ await secretsForm.locator('[name=name]').fill("MY_SECRET");
610+ await secretsForm.locator('[name=value]').fill("super-secret-value");
611+ await secretsForm.locator('[name=description]').fill("A test secret");
612+ await secretsForm.locator('button[type=submit]').click();
613+ await page.waitForURL(/settings/);
614+ // Secret name is shown, value masked
615+ expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1);
616+ expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0);
617+
618+ // Delete it
619+ const deleteBtn = page
620+ .locator(".label-settings-item")
621+ .filter({ hasText: "MY_SECRET" })
622+ .locator('button:text("Delete")');
623+ await deleteBtn.click();
624+ await page.waitForURL(/settings/);
625+ expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0);
626+ } finally {
627+ await page.close();
628+ }
629+ });
630+
631+ test("secret value is masked in step logs", async () => {
632+ // Add secret
633+ await db
634+ .insertInto("ci_secrets")
635+ .values({
636+ repo_id: (await db
637+ .selectFrom("repositories")
638+ .select("id")
639+ .where("name", "=", "ci-repo")
640+ .executeTakeFirstOrThrow()).id,
641+ name: "MASK_ME",
642+ value: "s3cr3t-p4ssw0rd",
643+ })
644+ .execute();
645+
646+ // Step echoes the secret value; mock returns it as output
647+ queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 });
648+ const runId = await triggerRun("ci-repo", {
649+ triggerSource: "manual",
650+ commitSha: ciRepoSha,
651+ commitBranch: "main",
652+ triggeredBy: adminUserId,
653+ });
654+ await waitForRun(runId);
655+
656+ const step = await db
657+ .selectFrom("ci_steps")
658+ .select("log")
659+ .where("run_id", "=", runId)
660+ .where("name", "=", "hello")
661+ .executeTakeFirst();
662+
663+ expect(step?.log).not.toContain("s3cr3t-p4ssw0rd");
664+ expect(step?.log).toContain("[MASKED]");
665+
666+ // Cleanup
667+ await db
668+ .deleteFrom("ci_secrets")
669+ .where("name", "=", "MASK_ME")
670+ .execute();
671+ });
672+});
Mtests/e2e.issues.test.ts
@@ -257,7 +257,7 @@ describe('issue editing', () => {
257257 try {
258258 await page.goto(issueUrl);
259259 // Edit title via title form
260- await page.click('details.title-edit-details summary');
260+ await page.click('.title-edit-open');
261261 await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
262262 await page.click('.title-edit-form-area [type=submit]');
263263 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
Mtests/e2e.patches.test.ts
@@ -393,7 +393,7 @@ describe('patches', () => {
393393 try {
394394 await page.goto(conflictPatchUrl);
395395 // Edit title via title form
396- await page.click('details.title-edit-details summary');
396+ await page.click('.title-edit-open');
397397 await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
398398 await page.click('.title-edit-form-area [type=submit]');
399399 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));