import type { RepositoryRow } from "../../db/index.ts"; import { formatDateTime } from "../../lib/formatDate.ts"; import type { SessionUser } from "../../middleware/session.ts"; import type { CiVariableDef } from "../../services/ci.ts"; import { Layout } from "../layout.tsx"; import { Pagination, type PaginationInfo } from "../Pagination.tsx"; import { RepoHeader } from "../repos/RepoHeader.tsx"; import { RepoNav } from "../repos/RepoNav.tsx"; import { CiStatusPill } from "./CiStatusPill.tsx"; interface RunSummary { id: number; repo_run_id: number | null; status: string; trigger_source: string; commit_sha: string | null; commit_branch: string | null; commit_tag: string | null; started_at: string | null; finished_at: string | null; created_at: string; triggered_by_username: string | null; artifact_count: number; queue_position: number | null; } interface CiHistoryProps { user: SessionUser | null; repo: RepositoryRow; runs: RunSummary[]; pagination: PaginationInfo; manualTriggerDisabledReason: string | null; /** Declared in [variables]. Null when the config could not be read. */ ciVariables: Record | null; } function queueTitle(position: number | null): string { if (position === null) return "Waiting in the build queue"; if (position === 1) return "Waiting in the build queue — next up"; return `Waiting in the build queue — ${position - 1} run${position - 1 === 1 ? "" : "s"} ahead`; } function duration(start: string | null, end: string | null): string { if (!start || !end) return ""; const ms = new Date(end).getTime() - new Date(start).getTime(); if (ms < 0) return ""; const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const rem = s % 60; return rem > 0 ? `${m}m ${rem}s` : `${m}m`; } const CI_VARIABLES = [ ["CI", "always true"], ["CI_PIPELINE_ID", "numeric run ID"], ["CI_COMMIT_SHA", "full commit hash"], ["CI_COMMIT_SHORT_SHA", "first 8 chars"], ["CI_COMMIT_BRANCH", "branch name (empty for tags)"], ["CI_COMMIT_TAG", "tag name (empty for branches)"], ["CI_COMMIT_REF_NAME", "branch or tag name"], ["CI_TRIGGER_SOURCE", "push · tag · manual"], ["CI_REPO_NAME", "repository name"], ["CI_SERVER_URL", "HearthForge base URL"], ]; function CiHelp({ repo }: { repo: RepositoryRow }) { return (
How to define a pipeline file Download template

Add .hearthforge-ci.toml to your repository root. Each [[steps]] entry is a step, executed in file order, and needs a name. Other tables:{" "} [on] (triggers), [variables]{" "} (user-overridable inputs) and [[copy]] (files taken from another image, like COPY --from).

Predefined variables

{CI_VARIABLES.map(([name, desc]) => ( <>
{name}
{desc}
))}

Status badge

Embed in your README:

{`![pipeline](/${repo.name}/ci/badge.svg)`}

Step options

{[ ["run_sh", "shell command to run"], ["run_if", "skip the step unless this exits 0"], [ "always", "run even after an earlier step failed", ], [ "warn_on_fail", "step warns instead of failing; the run reports warning", ], [ "clear", "re-extract a clean checkout before the step", ], ["timeout", "per-step timeout in seconds"], ].map(([k, v]) => ( <>
{k}
{v}
))}

Artifact types

{[ ["publish_file", "copy file as-is"], ["publish_tar", ".tar archive"], ["publish_gzip", ".tar.gz archive"], ["publish_zstd", ".tar.zst archive"], ["publish_zip", ".zip archive"], ].map(([k, v]) => ( <>
{k}
{v}
))}
); } /** * Manual trigger. With no declared variables it is a plain submit button. * With variables it opens a panel of inputs seeded with their defaults, so a * field left untouched sends the default back and counts as no override. */ function RunPipeline({ repo, disabledReason, variables, }: { repo: RepositoryRow; disabledReason: string | null; variables: Array<[string, CiVariableDef]>; }) { const form = (
{variables.length > 0 && (
{variables.map(([name, def]) => ( ))}
)}
); if (variables.length === 0) return
{form}
; return (
Run pipeline
{form}
); } export function CiHistory({ user, repo, runs, pagination, manualTriggerDisabledReason, ciVariables, }: CiHistoryProps) { const varEntries = Object.entries(ciVariables ?? {}); const isRunning = runs.some( (r) => r.status === "pending" || r.status === "running" || r.status === "queued", ); return ( {isRunning ? : null}

Pipelines

{user?.isAdmin && (
)}
{runs.length === 0 ? (

No pipeline runs yet.

Push a .hearthforge-ci.toml to your repository to get started.

) : (
    {runs.map((run) => (
  • #{run.repo_run_id ?? run.id}
    {!!run.commit_sha && ( {run.commit_sha.slice(0, 8)} )} {!!run.commit_branch && ( {run.commit_branch} )} {!!run.commit_tag && ( {run.commit_tag} )} {run.trigger_source} {!!run.triggered_by_username && ( by{" "} {run.triggered_by_username} )} {run.artifact_count > 0 && ( {run.artifact_count}{" "} artifact {run.artifact_count !== 1 ? "s" : ""} )} {!!run.started_at && !!run.finished_at && ( {duration( run.started_at, run.finished_at, )} )}
  • ))}
)}
); }