add pipelines
MREADME.md
| @@ -106,5 +106,4 @@ bun run test # E2E and unit tests (uses Playwright; don't call bun test | |||
|---|---|---|---|
| 106 | 106 | ## Roadmap | |
| 107 | 107 | ||
| 108 | 108 | - 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 @@ | |||
|---|---|---|---|
| 19 | 19 | "marked": "^17.0.4", | |
| 20 | 20 | "sharp": "^0.34.5", | |
| 21 | 21 | "shiki": "^4.0.2", | |
| 22 | + | "smol-toml": "^1.6.1", | |
| 22 | 23 | "ssh2": "^1.17.0", | |
| 23 | 24 | }, | |
| 24 | 25 | "devDependencies": { | |
| @@ -407,6 +408,8 @@ | |||
|---|---|---|---|
| 407 | 408 | ||
| 408 | 409 | "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=="], | |
| 409 | 410 | ||
| 411 | + | "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], | |
| 412 | + | ||
| 410 | 413 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | |
| 411 | 414 | ||
| 412 | 415 | "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], | |
Mpackage.json
| @@ -45,6 +45,7 @@ | |||
|---|---|---|---|
| 45 | 45 | "marked": "^17.0.4", | |
| 46 | 46 | "sharp": "^0.34.5", | |
| 47 | 47 | "shiki": "^4.0.2", | |
| 48 | + | "smol-toml": "^1.6.1", | |
| 48 | 49 | "ssh2": "^1.17.0" | |
| 49 | 50 | } | |
| 50 | 51 | } | |
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) | |||
|---|---|---|---|
| 8 | 8 | .sort(); | |
| 9 | 9 | ||
| 10 | 10 | const STALL_TIMEOUT = 20_000; // kill if no output for 20s | |
| 11 | - | const MAX_RETRIES = 2; | |
| 11 | + | const MAX_RETRIES = 3; | |
| 12 | 12 | // retry logic needed because tests get randomly get stuck on startup with bun | |
| 13 | 13 | // strace shows bun completely spinning in futex and not doing anything else | |
| 14 | 14 | function runTest(filePath: string): Promise<boolean> { | |
Msrc/app.ts
| @@ -4,6 +4,7 @@ import { Elysia } from "elysia"; | |||
|---|---|---|---|
| 4 | 4 | import config from "./config.ts"; | |
| 5 | 5 | import { authRoutes } from "./routes/auth.tsx"; | |
| 6 | 6 | import { avatarRoutes } from "./routes/avatars.ts"; | |
| 7 | + | import { ciRoutes } from "./routes/ci.tsx"; | |
| 7 | 8 | import { gitRoutes } from "./routes/git.ts"; | |
| 8 | 9 | import { issueRoutes } from "./routes/issues.tsx"; | |
| 9 | 10 | import { patchRoutes } from "./routes/patches.tsx"; | |
| @@ -30,6 +31,7 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 30 | 31 | .use(issueRoutes) | |
| 31 | 32 | .use(patchRoutes) | |
| 32 | 33 | .use(releasesRoutes) | |
| 34 | + | .use(ciRoutes) | |
| 33 | 35 | .use(avatarRoutes) | |
| 34 | 36 | .listen(port); | |
| 35 | 37 | } | |
Msrc/config.ts
| @@ -31,6 +31,10 @@ const config = { | |||
|---|---|---|---|
| 31 | 31 | MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000, | |
| 32 | 32 | MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64, | |
| 33 | 33 | 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, | |
| 34 | 38 | }; | |
| 35 | 39 | ||
| 36 | 40 | // Derived values that depend on other config fields | |
Msrc/constants.ts
| @@ -64,6 +64,7 @@ export const COMMITS_PER_PAGE = 20; | |||
|---|---|---|---|
| 64 | 64 | export const ISSUES_PER_PAGE = 20; | |
| 65 | 65 | export const PATCHES_PER_PAGE = 20; | |
| 66 | 66 | export const RELEASES_PER_PAGE = 20; | |
| 67 | + | export const CI_RUNS_PER_PAGE = 20; | |
| 67 | 68 | export const BRANCHES_PER_PAGE = 30; | |
| 68 | 69 | export const TAGS_PER_PAGE = 30; | |
| 69 | 70 | ||
| @@ -99,4 +100,7 @@ export const paths = { | |||
|---|---|---|---|
| 99 | 100 | get ALLOWED_SIGNERS_PATH() { | |
| 100 | 101 | return path.join(config.DATA_DIR, "allowed_signers"); | |
| 101 | 102 | }, | |
| 103 | + | get CI_ARTIFACTS_DIR() { | |
| 104 | + | return path.join(config.DATA_DIR, "ci", "artifacts"); | |
| 105 | + | }, | |
| 102 | 106 | }; | |
Msrc/db/index.ts
| @@ -154,6 +154,49 @@ interface PatchLabelTable { | |||
|---|---|---|---|
| 154 | 154 | label_id: number; | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 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 | + | ||
| 157 | 200 | export interface Database { | |
| 158 | 201 | users: UserTable; | |
| 159 | 202 | passkeys: PasskeyTable; | |
| @@ -171,6 +214,10 @@ export interface Database { | |||
|---|---|---|---|
| 171 | 214 | labels: LabelTable; | |
| 172 | 215 | issue_labels: IssueLabelTable; | |
| 173 | 216 | patch_labels: PatchLabelTable; | |
| 217 | + | ci_runs: CiRunTable; | |
| 218 | + | ci_steps: CiStepTable; | |
| 219 | + | ci_artifacts: CiArtifactTable; | |
| 220 | + | ci_secrets: CiSecretTable; | |
| 174 | 221 | } | |
| 175 | 222 | ||
| 176 | 223 | // Selectable row types (id is plain number, as returned by queries) | |
| @@ -188,6 +235,10 @@ export type SshKeyRow = Selectable<SshKeyTable>; | |||
|---|---|---|---|
| 188 | 235 | export type ReleaseRow = Selectable<ReleaseTable>; | |
| 189 | 236 | export type ReleaseAssetRow = Selectable<ReleaseAssetTable>; | |
| 190 | 237 | 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>; | |
| 191 | 242 | ||
| 192 | 243 | let sqlite = new BunDatabase(paths.DB_PATH); | |
| 193 | 244 | sqlite.run("PRAGMA journal_mode=WAL"); | |
| @@ -236,6 +287,48 @@ export function resetDb() { | |||
|---|---|---|---|
| 236 | 287 | }); | |
| 237 | 288 | } | |
| 238 | 289 | ||
| 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 | + | ||
| 239 | 332 | // Migration: add allow_user_labels column to repositories if missing | |
| 240 | 333 | const repoCols = sqlite | |
| 241 | 334 | .query<{ name: string }, []>("PRAGMA table_info(repositories)") | |
Msrc/db/schema.sql
| @@ -157,3 +157,47 @@ CREATE TABLE IF NOT EXISTS patch_labels ( | |||
|---|---|---|---|
| 157 | 157 | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 158 | 158 | PRIMARY KEY (patch_id, label_id) | |
| 159 | 159 | ); | |
| 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() | |||
|---|---|---|---|
| 151 | 151 | status: 403, | |
| 152 | 152 | }); | |
| 153 | 153 | 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 | + | ) { | |
| 155 | 161 | return html( | |
| 156 | 162 | <Register | |
| 157 | 163 | 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"; | |||
|---|---|---|---|
| 4 | 4 | import { Elysia, t } from "elysia"; | |
| 5 | 5 | import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 6 | 6 | 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 | + | } | |
| 8 | 84 | ||
| 9 | 85 | function pktLine(str: string): Buffer { | |
| 10 | 86 | const len = Buffer.byteLength(str, "utf-8") + 4; | |
| @@ -51,7 +127,7 @@ function unauthorized(): Response { | |||
|---|---|---|---|
| 51 | 127 | ||
| 52 | 128 | async function getRepo( | |
| 53 | 129 | slug: string, | |
| 54 | - | ): Promise<{ repoPath: string; isPrivate: boolean } | null> { | |
| 130 | + | ): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> { | |
| 55 | 131 | const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug; | |
| 56 | 132 | if (!VALID_REPO_NAME_RE.test(repoName)) return null; | |
| 57 | 133 | const repo = await db | |
| @@ -62,7 +138,7 @@ async function getRepo( | |||
|---|---|---|---|
| 62 | 138 | if (!repo) return null; | |
| 63 | 139 | const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`); | |
| 64 | 140 | if (!existsSync(repoPath)) return null; | |
| 65 | - | return { repoPath, isPrivate: repo.is_private === 1 }; | |
| 141 | + | return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 }; | |
| 66 | 142 | } | |
| 67 | 143 | ||
| 68 | 144 | async function spawnGit( | |
| @@ -165,11 +241,14 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 165 | 241 | const repo = await getRepo(params.repo); | |
| 166 | 242 | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 167 | 243 | const body = new Uint8Array(await request.arrayBuffer()); | |
| 244 | + | const refUpdates = parseRefUpdates(body); | |
| 168 | 245 | const result = await spawnGit( | |
| 169 | 246 | ["git", "receive-pack", "--stateless-rpc", repo.repoPath], | |
| 170 | 247 | body, | |
| 171 | 248 | ); | |
| 172 | 249 | invalidateRefCache(repo.name); | |
| 250 | + | // Trigger CI in background — don't block the git push response | |
| 251 | + | triggerCiForPush(repo.name, refUpdates).catch(() => {}); | |
| 173 | 252 | return new Response(result, { | |
| 174 | 253 | headers: { | |
| 175 | 254 | "Content-Type": "application/x-git-receive-pack-result", | |
Msrc/routes/repos.tsx
| @@ -757,12 +757,20 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 757 | 757 | const repo = await getRepo(params.repo, true); | |
| 758 | 758 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 759 | 759 | 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 | + | ]); | |
| 766 | 774 | const success = | |
| 767 | 775 | typeof query.success === "string" ? query.success : undefined; | |
| 768 | 776 | const error = typeof query.error === "string" ? query.error : undefined; | |
| @@ -772,6 +780,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 772 | 780 | repo={repo} | |
| 773 | 781 | branches={branches} | |
| 774 | 782 | labels={labels} | |
| 783 | + | secrets={secrets} | |
| 775 | 784 | success={success} | |
| 776 | 785 | error={error} | |
| 777 | 786 | />, | |
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 @@ | |||
|---|---|---|---|
| 1 | 1 | import DOMPurify from "isomorphic-dompurify"; | |
| 2 | 2 | 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"; | |
| 4 | 8 | ||
| 5 | 9 | marked.setOptions({ gfm: true }); | |
| 6 | 10 | ||
| @@ -181,14 +185,18 @@ export function markdownToPlaintext(md: string): string { | |||
|---|---|---|---|
| 181 | 185 | * Returns a short single-line preview of a plaintext string: | |
| 182 | 186 | * the first paragraph/heading line, truncated to maxLen chars. | |
| 183 | 187 | */ | |
| 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 { | |
| 185 | 192 | const firstBlock = text.split("\n\n")[0]?.trim() ?? ""; | |
| 186 | 193 | const firstLine = firstBlock.split("\n")[0] ?? ""; | |
| 187 | 194 | if (firstLine.length <= maxLen) return firstLine; | |
| 188 | 195 | const truncated = firstLine.slice(0, maxLen); | |
| 189 | 196 | const lastSpace = truncated.lastIndexOf(" "); | |
| 190 | 197 | 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) + "…" | |
| 193 | 201 | ); | |
| 194 | 202 | } | |
Msrc/styles/components.css
| @@ -1927,4 +1927,216 @@ | |||
|---|---|---|---|
| 1927 | 1927 | align-items: center; | |
| 1928 | 1928 | gap: var(--space-2); | |
| 1929 | 1929 | } | |
| 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 | + | } | |
| 1930 | 2142 | } | |
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">{``}</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"; | |
| 1 | 2 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 3 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 3 | 4 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | 5 | import type { BranchInfo } from "../../services/git.ts"; | |
| 5 | - | import { MAX_BRANCH_NAME_LENGTH } from "../../constants.ts"; | |
| 6 | 6 | import { Layout } from "../layout.tsx"; | |
| 7 | 7 | import { Pagination } from "../Pagination.tsx"; | |
| 8 | 8 | import { RepoHeader } from "./RepoHeader.tsx"; | |
| @@ -154,7 +154,9 @@ export function BranchList({ | |||
|---|---|---|---|
| 154 | 154 | required | |
| 155 | 155 | placeholder="new-name" | |
| 156 | 156 | value={b.name} | |
| 157 | - | maxlength={MAX_BRANCH_NAME_LENGTH} | |
| 157 | + | maxlength={ | |
| 158 | + | MAX_BRANCH_NAME_LENGTH | |
| 159 | + | } | |
| 158 | 160 | /> | |
| 159 | 161 | <button | |
| 160 | 162 | type="submit" | |
Msrc/views/repos/FileEdit.tsx
| @@ -1,6 +1,6 @@ | |||
|---|---|---|---|
| 1 | + | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 1 | 2 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 3 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | - | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 4 | 4 | import { Layout } from "../layout.tsx"; | |
| 5 | 5 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 6 | 6 | import { RepoNav } from "./RepoNav.tsx"; | |
Msrc/views/repos/NewFileForm.tsx
| @@ -1,6 +1,6 @@ | |||
|---|---|---|---|
| 1 | + | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 1 | 2 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 3 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | - | import { MAX_FILE_PATH_LENGTH } from "../../constants.ts"; | |
| 4 | 4 | import { Layout } from "../layout.tsx"; | |
| 5 | 5 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 6 | 6 | import { RepoNav } from "./RepoNav.tsx"; | |
Msrc/views/repos/RepoSettings.tsx
| @@ -1,4 +1,12 @@ | |||
|---|---|---|---|
| 1 | 1 | 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 | + | ||
| 2 | 10 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | 11 | import { Layout } from "../layout.tsx"; | |
| 4 | 12 | import { RepoHeader } from "./RepoHeader.tsx"; | |
| @@ -9,6 +17,7 @@ interface RepoSettingsProps { | |||
|---|---|---|---|
| 9 | 17 | repo: RepositoryRow; | |
| 10 | 18 | branches: string[]; | |
| 11 | 19 | labels: LabelRow[]; | |
| 20 | + | secrets: SecretSummary[]; | |
| 12 | 21 | success?: string; | |
| 13 | 22 | error?: string; | |
| 14 | 23 | } | |
| @@ -18,6 +27,7 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 18 | 27 | repo, | |
| 19 | 28 | branches, | |
| 20 | 29 | labels, | |
| 30 | + | secrets, | |
| 21 | 31 | success, | |
| 22 | 32 | error, | |
| 23 | 33 | }: RepoSettingsProps) { | |
| @@ -205,6 +215,81 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 205 | 215 | </button> | |
| 206 | 216 | </form> | |
| 207 | 217 | </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> | |
| 208 | 293 | <div class="danger-zone"> | |
| 209 | 294 | <h2 class="section-title danger-title">Danger zone</h2> | |
| 210 | 295 | <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"; | |
| 1 | 5 | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | 6 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 3 | 7 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | 8 | import type { TagInfo } from "../../services/git.ts"; | |
| 5 | - | import { MAX_TAG_MESSAGE_LENGTH, MAX_TAG_NAME_LENGTH } from "../../constants.ts"; | |
| 6 | 9 | import { Layout } from "../layout.tsx"; | |
| 7 | 10 | import { Pagination } from "../Pagination.tsx"; | |
| 8 | 11 | 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', () => { | |||
|---|---|---|---|
| 257 | 257 | try { | |
| 258 | 258 | await page.goto(issueUrl); | |
| 259 | 259 | // Edit title via title form | |
| 260 | - | await page.click('details.title-edit-details summary'); | |
| 260 | + | await page.click('.title-edit-open'); | |
| 261 | 261 | await page.fill('.title-edit-form-area [name=title]', 'Edited issue title'); | |
| 262 | 262 | await page.click('.title-edit-form-area [type=submit]'); | |
| 263 | 263 | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
Mtests/e2e.patches.test.ts
| @@ -393,7 +393,7 @@ describe('patches', () => { | |||
|---|---|---|---|
| 393 | 393 | try { | |
| 394 | 394 | await page.goto(conflictPatchUrl); | |
| 395 | 395 | // Edit title via title form | |
| 396 | - | await page.click('details.title-edit-details summary'); | |
| 396 | + | await page.click('.title-edit-open'); | |
| 397 | 397 | await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch'); | |
| 398 | 398 | await page.click('.title-edit-form-area [type=submit]'); | |
| 399 | 399 | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |