ci: variable form for manual runs, setup as first step, header layout

The "Run pipeline" button posted an empty form, so Elysia left `body`
undefined and the route threw whenever the config declared a variable.
Nothing rendered a var_ input either, so overrides were unreachable
anyway. The trigger is now a panel of inputs seeded with each default.
A field left untouched is not recorded as an override.

"pipeline setup" was inserted only on failure, after the config's step
rows, so it sorted last. It is now inserted first and is a real step
covering everything up to the first command. Successful runs show it
too, with its duration.

The run detail header used .release-detail-header, which had no CSS
anywhere in the project. The header was an unstyled block, so the
actions stacked under the metadata with no spacing. Renamed to
.ci-run-header with a flex row, and the buttons drop btn-sm and its
4px vertical padding. Checked at 320-768px: the actions wrap to their
own row and fit.

The run route had no tests. Added three, including an empty POST that
returns 500 without the fix.
AuthorKonata <konata@posteo.jp>
Date
Commitf3ee7b977e89ada154c31e9278632cdc3de72039
Parent71fc94f
6 files changed, 296 insertions(+), 62 deletions(-)
▾Msrc/routes/ci.tsx
@@ -7,6 +7,7 @@ import { contentDisposition } from "../lib/contentDisposition.ts";
import { paginate } from "../lib/pagination.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import {
type CiVariableDef,
cancelRun,
ciQueuePosition,
parseCiConfig,
@@ -155,6 +156,7 @@ export const ciRoutes = new Elysia()
// Determine why manual trigger may be unavailable (admin-only check)
let manualTriggerDisabledReason: string | null = null;
let ciVariables: Record<string, CiVariableDef> | null = null;
if (user?.isAdmin) {
const branches = await git.branches(repo.name);
const defaultBranch = repo.default_branch || branches[0];
@@ -181,6 +183,8 @@ export const ciRoutes = new Elysia()
if (!cfg) {
manualTriggerDisabledReason =
"Failed to parse .hearthforge-ci.toml";
} else {
ciVariables = cfg.variables ?? null;
}
}
}
@@ -198,6 +202,7 @@ export const ciRoutes = new Elysia()
pageUrlTemplate: `/${repo.name}/ci?page={page}`,
}}
manualTriggerDisabledReason={manualTriggerDisabledReason}
ciVariables={ciVariables}
/>,
);
},
@@ -301,14 +306,16 @@ export const ciRoutes = new Elysia()
);
// Parse variable overrides from form body
// Elysia leaves body undefined for a POST with an empty body, which
// is what a form with no filled-in inputs sends.
const form = (body ?? {}) as Record<string, string>;
const variableOverrides: Record<string, string> = {};
if (cfg.variables) {
for (const varName of Object.keys(cfg.variables)) {
const formKey = `var_${varName}`;
const val = (body as Record<string, string>)[formKey];
if (typeof val === "string") {
variableOverrides[varName] = val;
}
for (const [varName, def] of Object.entries(cfg.variables ?? {})) {
const val = form[`var_${varName}`];
// An untouched field is not an override. Sending the default back
// would pin the run to the value the config had at render time.
if (typeof val === "string" && val !== (def.default ?? "")) {
variableOverrides[varName] = val;
}
}
▾Msrc/services/ci.ts
@@ -8,7 +8,7 @@ import { gitEnv, repoPath } from "./git.ts";
// --- Types ---
interface CiVariableDef {
export interface CiVariableDef {
default?: string;
description?: string;
}
@@ -1089,6 +1089,23 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
let pruneCaches: { repoName: string; cache?: CiCache[] } | null = null;
let cacheLimits: { repoName: string; cache?: CiCache[] } | null = null;
// Inserted before any config step, so ordering by id puts it first. It
// covers everything up to the first step: config parsing, the image pull,
// the container, the copies, and the checkout.
// Not "setup": a pipeline may well have its own step by that name.
const setupStepId = (
await db
.insertInto("ci_steps")
.values({
run_id: runId,
name: "pipeline setup",
status: "running",
started_at: now(),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
try {
// Mark as running
await db
@@ -1209,6 +1226,12 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
);
}
await db
.updateTable("ci_steps")
.set({ status: "success", finished_at: now() })
.where("id", "=", setupStepId)
.execute();
// Execute steps
const shell = cfg.shell ?? ["/bin/sh", "-c"];
let runFailed = false;
@@ -1428,31 +1451,22 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
? "Skipped: Docker/Podman not available"
: "Skipped: run failed";
if (!isDockerUnavailable) {
// A failed step already carries its own log. Failures outside any
// step (validation, upload, clone) would leave no reason at all.
const failedStep = await db
.selectFrom("ci_steps")
.select("id")
.where("run_id", "=", runId)
.where("status", "=", "failure")
.executeTakeFirst();
if (!failedStep) {
await db
.insertInto("ci_steps")
.values({
run_id: runId,
// Not "setup": a pipeline may well have its own step
// by that name.
name: "pipeline setup",
status: "failure",
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
log: `Error: ${errMsg}\n`,
})
.execute();
}
}
// Only while setup still owns the run. Once it succeeds, a failure
// belongs to a step, and that step carries its own log.
await db
.updateTable("ci_steps")
.set({
status: signal.aborted
? "cancelled"
: isDockerUnavailable
? "skipped"
: "failure",
finished_at: new Date().toISOString(),
log: isDockerUnavailable ? skipLog : `Error: ${errMsg}\n`,
})
.where("id", "=", setupStepId)
.where("status", "=", "running")
.execute();
await db
.updateTable("ci_runs")
.set({ status, finished_at: new Date().toISOString() })
▾Msrc/styles/components.css
@@ -2015,10 +2015,43 @@
padding: 1px 4px;
border-radius: 3px;
}
/* Header of the run detail page: title block left, actions right. */
.ci-run-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.ci-run-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
}
/* Manual trigger panel, shown when the config declares [variables]. */
.ci-run-popup {
display: block;
white-space: normal;
width: 20rem;
max-width: 90vw;
text-align: left;
}
.ci-run-vars {
max-height: 50vh;
overflow-y: auto;
margin-bottom: var(--space-3);
}
.ci-run-var:last-child {
margin-bottom: 0;
}
.ci-run-var-desc {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-weight: 400;
margin-top: calc(-1 * var(--space-1));
}
.ci-steps {
margin-top: var(--space-4);
▾Msrc/views/ci/CiHistory.tsx
@@ -1,6 +1,7 @@
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";
@@ -29,6 +30,8 @@ interface CiHistoryProps {
runs: RunSummary[];
pagination: PaginationInfo;
manualTriggerDisabledReason: string | null;
/** Declared in [variables]. Null when the config could not be read. */
ciVariables: Record<string, CiVariableDef> | null;
}
function queueTitle(position: number | null): string {
@@ -167,13 +170,77 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
);
}
/**
* 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 = (
<form method="POST" action={`/${repo.name}/ci/run`}>
{variables.length > 0 && (
<div class="ci-run-vars">
{variables.map(([name, def]) => (
<label class="form-group ci-run-var">
<code safe>{name}</code>
{!!def.description && (
<span class="ci-run-var-desc" safe>
{def.description}
</span>
)}
<input
type="text"
name={`var_${name}`}
value={def.default ?? ""}
autocomplete="off"
/>
</label>
))}
</div>
)}
<button
type="submit"
class="btn btn-primary btn-sm"
disabled={disabledReason ? true : undefined}
title={disabledReason ?? undefined}
>
Run pipeline
</button>
</form>
);
if (variables.length === 0) return <div class="inline-form">{form}</div>;
return (
<details class="confirm-details ci-run-details">
<summary
class="btn btn-primary btn-sm"
title={disabledReason ?? undefined}
>
Run pipeline
</summary>
<div class="confirm-popup ci-run-popup">{form}</div>
</details>
);
}
export function CiHistory({
user,
repo,
runs,
pagination,
manualTriggerDisabledReason,
ciVariables,
}: CiHistoryProps) {
const varEntries = Object.entries(ciVariables ?? {});
const isRunning = runs.some(
(r) =>
r.status === "pending" ||
@@ -203,26 +270,11 @@ export function CiHistory({
Purge caches
</button>
</form>
<form
method="POST"
action={`/${repo.name}/ci/run`}
class="inline-form"
>
<button
type="submit"
class="btn btn-primary btn-sm"
disabled={
manualTriggerDisabledReason
? true
: undefined
}
title={
manualTriggerDisabledReason ?? undefined
}
>
Run pipeline
</button>
</form>
<RunPipeline
repo={repo}
disabledReason={manualTriggerDisabledReason}
variables={varEntries}
/>
</div>
)}
</div>
▾Msrc/views/ci/CiRunDetail.tsx
@@ -94,7 +94,7 @@ export function CiRunDetail({
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
<div class="release-detail-header">
<div class="ci-run-header">
<div>
<h2 class="release-detail-title">
<CiStatusPill
@@ -150,7 +150,7 @@ export function CiRunDetail({
{isActive && (
<a
href={autoRefresh ? "?refresh=off" : "?"}
class="btn btn-secondary btn-sm"
class="btn btn-secondary"
>
{autoRefresh
? "Pause refresh"
@@ -166,7 +166,7 @@ export function CiRunDetail({
>
<button
type="submit"
class="btn btn-danger btn-sm"
class="btn btn-danger"
>
Cancel
</button>
@@ -179,7 +179,7 @@ export function CiRunDetail({
>
<button
type="submit"
class="btn btn-secondary btn-sm"
class="btn btn-secondary"
title="Re-run with the same commit, trigger source, and variable overrides"
>
Retry
▾Mtests/e2e.ci.test.ts
@@ -440,6 +440,127 @@ describe("pipelines tab", () => {
}
});
test("the run form posts and overrides a declared variable", async () => {
// Regression: an input-less form posts an empty body, and Elysia
// leaves `body` undefined. Indexing it crashed the route whenever the
// config declared a variable. Nothing rendered a var_ input either.
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
description = "What to echo"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const trigger = page.locator("details.ci-run-details");
await trigger.locator("summary").click();
const field = page.locator('input[name="var_GREETING"]');
expect(await field.inputValue()).toBe("hello");
await field.fill("goodbye");
await trigger.locator('button[type="submit"]').click();
await page.waitForURL(/\/ci\/\d+$/);
const runId = Number(page.url().split("/").pop());
const row = await db
.selectFrom("ci_runs")
.select("variable_overrides")
.where("id", "=", runId)
.executeTakeFirst();
expect(JSON.parse(row!.variable_overrides!)).toEqual({
GREETING: "goodbye",
});
} finally {
await page.close();
}
});
test("an untouched variable field is not recorded as an override", async () => {
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci`);
const trigger = page.locator("details.ci-run-details");
await trigger.locator("summary").click();
await trigger.locator('button[type="submit"]').click();
await page.waitForURL(/\/ci\/\d+$/);
const runId = Number(page.url().split("/").pop());
const row = await db
.selectFrom("ci_runs")
.select("variable_overrides")
.where("id", "=", runId)
.executeTakeFirst();
expect(JSON.parse(row!.variable_overrides ?? "{}")).toEqual({});
} finally {
await page.close();
}
});
test("an empty POST to the run route does not crash", async () => {
// A form with no filled inputs sends no body, and Elysia then leaves
// `body` undefined. Indexing it threw "undefined is not an object".
// The UI no longer produces this shape, so post it directly.
seedCiToml(
"ci-repo",
`
image = "debian:latest"
[on]
manual = true
[variables]
[variables.GREETING]
default = "hello"
[[steps]]
name = "say"
run_sh = "echo $GREETING"
`,
);
queueExec({ output: "hi\n", exitCode: 0 });
const resp = await adminCtx.request.post(`${BASE}/ci-repo/ci/run`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: "",
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
});
test("help section is collapsible and contains template download", async () => {
const page = await adminCtx.newPage();
try {
@@ -505,11 +626,18 @@ describe("successful run", () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ci-repo/ci/${runId}`);
expect(
await page.locator(".ci-step").first().textContent(),
).toContain("hello");
// Setup is a real step and sorts before the config's steps.
const first = await page
.locator(".ci-step")
.first()
.textContent();
expect(first).toContain("pipeline setup");
expect(first).toContain("success");
const hello = page.locator(".ci-step").nth(1);
expect(await hello.textContent()).toContain("hello");
// Open step details to see log
await page.locator(".ci-step").first().click();
await hello.click();
expect(await page.locator(".ci-step-log").textContent()).toContain(
"hello from mock CI",
);