add patch commit signing, show signatures in commit list and detail views
MREADME.md
| @@ -13,6 +13,7 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g | |||
|---|---|---|---|
| 13 | 13 | - **Releases** — releases with source archives, extra uploaded assets, and optional tag creation | |
| 14 | 14 | - **SSH push/pull** — built-in SSH server, no external git daemon needed | |
| 15 | 15 | - **Auth** — password login or passkeys (WebAuthn/FIDO2) | |
| 16 | + | - **Commit signing** — merged patches automatically signed and verification badges are shown in the commit list view | |
| 16 | 17 | - **Optional registration** — others can create accounts to file issues and patches; can be disabled | |
| 17 | 18 | ||
| 18 | 19 | ## Stack | |
| @@ -95,4 +96,5 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res | |||
|---|---|---|---|
| 95 | 96 | - Issue labels | |
| 96 | 97 | - Repository list reordering (e.g. last committed) and starring | |
| 97 | 98 | - redirect image urls in readme | |
| 98 | - | - commit signing | |
| 99 | + | - simple file editor | |
| 100 | + | - generic diff viewer (show diff for a given path between two refs) | |
Msrc/config.ts
| @@ -22,3 +22,5 @@ export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME; | |||
|---|---|---|---|
| 22 | 22 | export const COMMITTER_EMAIL = | |
| 23 | 23 | process.env.COMMITTER_EMAIL ?? | |
| 24 | 24 | `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`; | |
| 25 | + | export const EXTRA_ALLOWED_SIGNERS_PATH = | |
| 26 | + | process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null; | |
Msrc/constants.ts
| @@ -28,7 +28,7 @@ export const CHALLENGE_TTL_MS = 5 * 60 * 1000; | |||
|---|---|---|---|
| 28 | 28 | ||
| 29 | 29 | // Pagination | |
| 30 | 30 | export const REPOS_PER_PAGE = 20; | |
| 31 | - | export const COMMITS_PER_PAGE = 30; | |
| 31 | + | export const COMMITS_PER_PAGE = 20; | |
| 32 | 32 | export const ISSUES_PER_PAGE = 20; | |
| 33 | 33 | export const PATCHES_PER_PAGE = 20; | |
| 34 | 34 | export const RELEASES_PER_PAGE = 20; | |
| @@ -44,4 +44,6 @@ export const DB_PATH = path.join(DATA_DIR, "hearthforge.db"); | |||
|---|---|---|---|
| 44 | 44 | export const REPOS_DIR = path.join(DATA_DIR, "repos"); | |
| 45 | 45 | export const AVATARS_DIR = path.join(DATA_DIR, "avatars"); | |
| 46 | 46 | export const RELEASES_DIR = path.join(DATA_DIR, "releases"); | |
| 47 | - | export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key"); | |
| 47 | + | export const SSH_HOST_KEY_PATH = | |
| 48 | + | process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key"); | |
| 49 | + | export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers"); | |
Msrc/routes/issues.tsx
| @@ -108,7 +108,13 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 108 | 108 | if (deny) return deny; | |
| 109 | 109 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 110 | 110 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 111 | - | return html(<NewIssue user={user!} repo={repo} template={repo.issue_template ?? undefined} />); | |
| 111 | + | return html( | |
| 112 | + | <NewIssue | |
| 113 | + | user={user!} | |
| 114 | + | repo={repo} | |
| 115 | + | template={repo.issue_template ?? undefined} | |
| 116 | + | />, | |
| 117 | + | ); | |
| 112 | 118 | }) | |
| 113 | 119 | ||
| 114 | 120 | .post( | |
Msrc/routes/patches.tsx
| @@ -144,7 +144,13 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 144 | 144 | if (deny) return deny; | |
| 145 | 145 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 146 | 146 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 147 | - | return html(<NewPatch user={user!} repo={repo} template={repo.patch_template ?? undefined} />); | |
| 147 | + | return html( | |
| 148 | + | <NewPatch | |
| 149 | + | user={user!} | |
| 150 | + | repo={repo} | |
| 151 | + | template={repo.patch_template ?? undefined} | |
| 152 | + | />, | |
| 153 | + | ); | |
| 148 | 154 | }) | |
| 149 | 155 | ||
| 150 | 156 | .post( | |
Msrc/routes/repos.tsx
| @@ -1,8 +1,9 @@ | |||
|---|---|---|---|
| 1 | - | import { rmSync } from "node:fs"; | |
| 1 | + | import { readFileSync, rmSync } from "node:fs"; | |
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | 4 | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | 5 | import { | |
| 6 | + | ALLOWED_SIGNERS_PATH, | |
| 6 | 7 | COMMITS_PER_PAGE, | |
| 7 | 8 | REPOS_PER_PAGE, | |
| 8 | 9 | VALID_REPO_NAME_RE, | |
| @@ -58,6 +59,11 @@ async function readReadme( | |||
|---|---|---|---|
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | export const repoRoutes = new Elysia() | |
| 62 | + | .get("/allowed_signers", () => { | |
| 63 | + | return new Response(readFileSync(ALLOWED_SIGNERS_PATH), { | |
| 64 | + | headers: { "Content-Type": "text/plain; charset=utf-8" }, | |
| 65 | + | }); | |
| 66 | + | }) | |
| 61 | 67 | .guard({ | |
| 62 | 68 | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 63 | 69 | }) | |
| @@ -569,7 +575,13 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 569 | 575 | const repo = await getRepo(params.repo, true); | |
| 570 | 576 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 571 | 577 | ||
| 572 | - | const { description, is_private, default_branch, issue_template, patch_template } = body; | |
| 578 | + | const { | |
| 579 | + | description, | |
| 580 | + | is_private, | |
| 581 | + | default_branch, | |
| 582 | + | issue_template, | |
| 583 | + | patch_template, | |
| 584 | + | } = body; | |
| 573 | 585 | ||
| 574 | 586 | const branches = await git.branches(repo.name); | |
| 575 | 587 | const newBranch = default_branch?.trim() || repo.default_branch; | |
Msrc/services/git.ts
| @@ -1,7 +1,11 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | import { $ as _$ } from "bun"; | |
| 3 | 3 | ||
| 4 | - | import { REPOS_DIR } from "../constants.ts"; | |
| 4 | + | import { | |
| 5 | + | ALLOWED_SIGNERS_PATH, | |
| 6 | + | REPOS_DIR, | |
| 7 | + | SSH_HOST_KEY_PATH, | |
| 8 | + | } from "../constants.ts"; | |
| 5 | 9 | ||
| 6 | 10 | const gitEnv = { | |
| 7 | 11 | ...process.env, | |
| @@ -116,6 +120,7 @@ export interface CommitEntry { | |||
|---|---|---|---|
| 116 | 120 | subject: string; | |
| 117 | 121 | author: string; | |
| 118 | 122 | date: string; | |
| 123 | + | sigStatus: "good" | "bad" | "none"; | |
| 119 | 124 | } | |
| 120 | 125 | ||
| 121 | 126 | export interface CommitMeta { | |
| @@ -129,6 +134,7 @@ export interface CommitMeta { | |||
|---|---|---|---|
| 129 | 134 | committerEmail: string; | |
| 130 | 135 | committerDate: string; | |
| 131 | 136 | parents: string[]; | |
| 137 | + | sigStatus: "good" | "bad" | "none"; | |
| 132 | 138 | } | |
| 133 | 139 | ||
| 134 | 140 | export interface TreeEntry { | |
| @@ -139,6 +145,13 @@ export interface TreeEntry { | |||
|---|---|---|---|
| 139 | 145 | name: string; | |
| 140 | 146 | } | |
| 141 | 147 | ||
| 148 | + | function parseSigStatus(code: string): "good" | "bad" | "none" { | |
| 149 | + | if (code === "G" || code === "X" || code === "Y" || code === "R") | |
| 150 | + | return "good"; | |
| 151 | + | if (code === "B" || code === "U" || code === "E") return "bad"; | |
| 152 | + | return "none"; | |
| 153 | + | } | |
| 154 | + | ||
| 142 | 155 | function parseLog(out: string): CommitEntry[] { | |
| 143 | 156 | return out | |
| 144 | 157 | .split("\n") | |
| @@ -150,6 +163,7 @@ function parseLog(out: string): CommitEntry[] { | |||
|---|---|---|---|
| 150 | 163 | subject: parts[1] ?? "", | |
| 151 | 164 | author: parts[2] ?? "", | |
| 152 | 165 | date: parts[3] ?? "", | |
| 166 | + | sigStatus: parseSigStatus(parts[4] ?? ""), | |
| 153 | 167 | }; | |
| 154 | 168 | }); | |
| 155 | 169 | } | |
| @@ -250,9 +264,15 @@ export const git = { | |||
|---|---|---|---|
| 250 | 264 | skip = 0, | |
| 251 | 265 | ): Promise<CommitEntry[]> { | |
| 252 | 266 | const p = repoPath(name); | |
| 267 | + | const sigArgs = [ | |
| 268 | + | "-c", | |
| 269 | + | "gpg.format=ssh", | |
| 270 | + | "-c", | |
| 271 | + | `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`, | |
| 272 | + | ]; | |
| 253 | 273 | try { | |
| 254 | 274 | const out = | |
| 255 | - | await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text(); | |
| 275 | + | await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text(); | |
| 256 | 276 | return parseLog(out); | |
| 257 | 277 | } catch { | |
| 258 | 278 | return []; | |
| @@ -441,12 +461,21 @@ export const git = { | |||
|---|---|---|---|
| 441 | 461 | await $`git -C ${p} rev-parse HEAD`.text() | |
| 442 | 462 | ).trim(); | |
| 443 | 463 | const msg = extractPatchSubject(patchContent); | |
| 464 | + | const sigArgs = [ | |
| 465 | + | "-c", | |
| 466 | + | "gpg.format=ssh", | |
| 467 | + | "-c", | |
| 468 | + | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 469 | + | ]; | |
| 444 | 470 | const commit = ( | |
| 445 | - | await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}` | |
| 471 | + | await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}` | |
| 446 | 472 | .env({ | |
| 447 | 473 | ...process.env, | |
| 448 | 474 | LC_ALL: "C", | |
| 449 | 475 | LANG: "C", | |
| 476 | + | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 477 | + | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 478 | + | GIT_CONFIG_COUNT: "0", | |
| 450 | 479 | GIT_AUTHOR_NAME: authorName, | |
| 451 | 480 | GIT_AUTHOR_EMAIL: authorEmail, | |
| 452 | 481 | GIT_COMMITTER_NAME: committerName, | |
| @@ -474,9 +503,15 @@ export const git = { | |||
|---|---|---|---|
| 474 | 503 | ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> { | |
| 475 | 504 | return withRepoLock(repoName, async () => { | |
| 476 | 505 | const p = repoPath(repoName); | |
| 506 | + | const sigArgs = [ | |
| 507 | + | "-c", | |
| 508 | + | "gpg.format=ssh", | |
| 509 | + | "-c", | |
| 510 | + | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 511 | + | ]; | |
| 477 | 512 | const result = | |
| 478 | 513 | message !== undefined | |
| 479 | - | ? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}` | |
| 514 | + | ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}` | |
| 480 | 515 | .env({ | |
| 481 | 516 | ...gitEnv, | |
| 482 | 517 | GIT_COMMITTER_NAME: taggerName!, | |
| @@ -524,9 +559,15 @@ export const git = { | |||
|---|---|---|---|
| 524 | 559 | ||
| 525 | 560 | async commitMeta(name: string, sha: string): Promise<CommitMeta | null> { | |
| 526 | 561 | const p = repoPath(name); | |
| 562 | + | const sigArgs = [ | |
| 563 | + | "-c", | |
| 564 | + | "gpg.format=ssh", | |
| 565 | + | "-c", | |
| 566 | + | `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`, | |
| 567 | + | ]; | |
| 527 | 568 | try { | |
| 528 | 569 | const [metaOut, msgOut] = await Promise.all([ | |
| 529 | - | $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(), | |
| 570 | + | $`git ${sigArgs} -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G? ${sha}`.text(), | |
| 530 | 571 | $`git -C ${p} log --format=%B -1 ${sha}`.text(), | |
| 531 | 572 | ]); | |
| 532 | 573 | const parts = metaOut.trim().split("\x1f"); | |
| @@ -551,6 +592,7 @@ export const git = { | |||
|---|---|---|---|
| 551 | 592 | committerEmail: parts[5] ?? "", | |
| 552 | 593 | committerDate: parts[6] ?? "", | |
| 553 | 594 | parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean), | |
| 595 | + | sigStatus: parseSigStatus(parts[8] ?? ""), | |
| 554 | 596 | }; | |
| 555 | 597 | } catch { | |
| 556 | 598 | return null; | |
Msrc/services/repoSync.ts
| @@ -2,12 +2,20 @@ import { | |||
|---|---|---|---|
| 2 | 2 | type Dirent, | |
| 3 | 3 | existsSync, | |
| 4 | 4 | readdirSync, | |
| 5 | + | readFileSync, | |
| 5 | 6 | renameSync, | |
| 6 | 7 | rmSync, | |
| 7 | 8 | } from "node:fs"; | |
| 8 | 9 | import path from "node:path"; | |
| 9 | 10 | import { $ } from "bun"; | |
| 10 | - | import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 11 | + | import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts"; | |
| 12 | + | import { | |
| 13 | + | ALLOWED_SIGNERS_PATH, | |
| 14 | + | RELEASES_DIR, | |
| 15 | + | REPOS_DIR, | |
| 16 | + | SSH_HOST_KEY_PATH, | |
| 17 | + | VALID_REPO_NAME_RE, | |
| 18 | + | } from "../constants.ts"; | |
| 11 | 19 | import type { RepositoryRow } from "../db/index.ts"; | |
| 12 | 20 | import { db } from "../db/index.ts"; | |
| 13 | 21 | import { git, repoPath } from "../services/git.ts"; | |
| @@ -106,7 +114,67 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> { | |||
|---|---|---|---|
| 106 | 114 | .executeTakeFirstOrThrow(); | |
| 107 | 115 | } | |
| 108 | 116 | ||
| 117 | + | async function ensureSigningSetup(): Promise<void> { | |
| 118 | + | const hostname = new URL(BASE_URL).hostname; | |
| 119 | + | const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`; | |
| 120 | + | ||
| 121 | + | if (!existsSync(SSH_HOST_KEY_PATH)) { | |
| 122 | + | await Bun.spawn([ | |
| 123 | + | "ssh-keygen", | |
| 124 | + | "-t", | |
| 125 | + | "ed25519", | |
| 126 | + | "-N", | |
| 127 | + | "", | |
| 128 | + | "-f", | |
| 129 | + | SSH_HOST_KEY_PATH, | |
| 130 | + | "-C", | |
| 131 | + | hostname, | |
| 132 | + | ]).exited; | |
| 133 | + | console.log("Generated SSH host key at", SSH_HOST_KEY_PATH); | |
| 134 | + | } else { | |
| 135 | + | try { | |
| 136 | + | const existing = readFileSync(pubKeyPath, "utf8").trim(); | |
| 137 | + | const keyHostname = existing.split(/\s+/)[2] ?? ""; | |
| 138 | + | if (keyHostname !== hostname) { | |
| 139 | + | console.warn( | |
| 140 | + | `Warning: SSH host key comment "${keyHostname}" does not match` + | |
| 141 | + | ` current hostname "${hostname}". The key was likely generated` + | |
| 142 | + | ` for a different BASE_URL. Commit signatures may show an` + | |
| 143 | + | ` unexpected identity.`, | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | } catch { | |
| 147 | + | // .pub file missing or unreadable — handled below | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | let pubKey: string; | |
| 152 | + | try { | |
| 153 | + | pubKey = readFileSync(pubKeyPath, "utf8").trim(); | |
| 154 | + | } catch { | |
| 155 | + | console.warn("Could not read SSH public key at", pubKeyPath); | |
| 156 | + | return; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | let content = `* namespaces="git" ${pubKey}\n`; | |
| 160 | + | ||
| 161 | + | if (EXTRA_ALLOWED_SIGNERS_PATH) { | |
| 162 | + | try { | |
| 163 | + | const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8"); | |
| 164 | + | content += extra.endsWith("\n") ? extra : `${extra}\n`; | |
| 165 | + | } catch { | |
| 166 | + | console.warn( | |
| 167 | + | "Could not read EXTRA_ALLOWED_SIGNERS_PATH:", | |
| 168 | + | EXTRA_ALLOWED_SIGNERS_PATH, | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | await Bun.write(ALLOWED_SIGNERS_PATH, content); | |
| 174 | + | } | |
| 175 | + | ||
| 109 | 176 | export async function syncStartup(): Promise<void> { | |
| 177 | + | await ensureSigningSetup(); | |
| 110 | 178 | await convertNonBareRepos(); | |
| 111 | 179 | const diskNames = new Set(listDiskRepoNames()); | |
| 112 | 180 | const dbRepos = await db | |
Msrc/services/sshServer.ts
| @@ -1,6 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import { type ChildProcess, spawn } from "node:child_process"; | |
| 2 | 2 | import { createHash } from "node:crypto"; | |
| 3 | - | import { existsSync, readFileSync } from "node:fs"; | |
| 3 | + | import { readFileSync } from "node:fs"; | |
| 4 | 4 | import path from "node:path"; | |
| 5 | 5 | import { Server, utils } from "ssh2"; | |
| 6 | 6 | import { SSH_PORT } from "../config.ts"; | |
| @@ -33,21 +33,6 @@ export function fingerprintFromLine(pubkeyLine: string): string | null { | |||
|---|---|---|---|
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | export async function startSshServer() { | |
| 36 | - | if (!existsSync(SSH_HOST_KEY_PATH)) { | |
| 37 | - | await Bun.spawn([ | |
| 38 | - | "ssh-keygen", | |
| 39 | - | "-t", | |
| 40 | - | "ed25519", | |
| 41 | - | "-N", | |
| 42 | - | "", | |
| 43 | - | "-f", | |
| 44 | - | SSH_HOST_KEY_PATH, | |
| 45 | - | "-C", | |
| 46 | - | "hearthforge-host", | |
| 47 | - | ]).exited; | |
| 48 | - | console.log("Generated SSH host key at", SSH_HOST_KEY_PATH); | |
| 49 | - | } | |
| 50 | - | ||
| 51 | 36 | const hostKey = readFileSync(SSH_HOST_KEY_PATH); | |
| 52 | 37 | ||
| 53 | 38 | const server = new Server({ hostKeys: [hostKey] }, (client) => { | |
Msrc/styles/main.css
| @@ -741,6 +741,22 @@ | |||
|---|---|---|---|
| 741 | 741 | background: var(--color-merged-bg); | |
| 742 | 742 | color: var(--color-merged); | |
| 743 | 743 | } | |
| 744 | + | .sig-badge { | |
| 745 | + | display: inline-flex; | |
| 746 | + | align-items: center; | |
| 747 | + | padding: var(--space-1) var(--space-3); | |
| 748 | + | border-radius: var(--radius-full); | |
| 749 | + | font-size: var(--text-xs); | |
| 750 | + | font-weight: 500; | |
| 751 | + | } | |
| 752 | + | .sig-badge.verified { | |
| 753 | + | background: var(--color-success-bg); | |
| 754 | + | color: var(--color-success); | |
| 755 | + | } | |
| 756 | + | .sig-badge.unverified { | |
| 757 | + | background: var(--color-danger-bg); | |
| 758 | + | color: var(--color-danger); | |
| 759 | + | } | |
| 744 | 760 | ||
| 745 | 761 | /* --- Empty state --- */ | |
| 746 | 762 | .empty-state { | |
| @@ -939,20 +955,14 @@ | |||
|---|---|---|---|
| 939 | 955 | } | |
| 940 | 956 | .commit-item { | |
| 941 | 957 | display: flex; | |
| 942 | - | align-items: flex-start; | |
| 943 | - | justify-content: space-between; | |
| 958 | + | flex-direction: column; | |
| 944 | 959 | padding: var(--space-3) 0; | |
| 945 | 960 | border-bottom: 1px solid var(--color-border-muted); | |
| 946 | - | gap: var(--space-4); | |
| 947 | - | flex-wrap: wrap; | |
| 961 | + | gap: var(--space-2); | |
| 948 | 962 | } | |
| 949 | 963 | .commit-item:last-child { | |
| 950 | 964 | border-bottom: none; | |
| 951 | 965 | } | |
| 952 | - | .commit-main { | |
| 953 | - | flex: 1; | |
| 954 | - | min-width: 0; | |
| 955 | - | } | |
| 956 | 966 | .commit-subject { | |
| 957 | 967 | color: var(--color-text); | |
| 958 | 968 | text-decoration: none; | |
| @@ -965,9 +975,15 @@ | |||
|---|---|---|---|
| 965 | 975 | .commit-meta { | |
| 966 | 976 | display: flex; | |
| 967 | 977 | align-items: center; | |
| 978 | + | justify-content: space-between; | |
| 968 | 979 | gap: var(--space-3); | |
| 969 | 980 | font-size: var(--text-xs); | |
| 970 | 981 | color: var(--color-text-muted); | |
| 982 | + | } | |
| 983 | + | .commit-meta-right { | |
| 984 | + | display: flex; | |
| 985 | + | align-items: center; | |
| 986 | + | gap: var(--space-3); | |
| 971 | 987 | white-space: nowrap; | |
| 972 | 988 | } | |
| 973 | 989 | .commit-hash { | |
| @@ -2392,6 +2408,14 @@ | |||
|---|---|---|---|
| 2392 | 2408 | /* ============================================================ | |
| 2393 | 2409 | Pagination | |
| 2394 | 2410 | ============================================================ */ | |
| 2411 | + | .commit-verify-hint { | |
| 2412 | + | margin-bottom: var(--space-4); | |
| 2413 | + | font-size: var(--text-xs); | |
| 2414 | + | color: var(--color-text-muted); | |
| 2415 | + | } | |
| 2416 | + | .commit-verify-hint code { | |
| 2417 | + | word-break: break-all; | |
| 2418 | + | } | |
| 2395 | 2419 | .commit-cursor-nav { | |
| 2396 | 2420 | display: flex; | |
| 2397 | 2421 | justify-content: space-between; | |
Msrc/views/patches/NewPatch.tsx
| @@ -42,11 +42,7 @@ export function NewPatch({ user, repo, error, template }: NewPatchProps) { | |||
|---|---|---|---|
| 42 | 42 | (Markdown supported, optional) | |
| 43 | 43 | </span> | |
| 44 | 44 | </label> | |
| 45 | - | <textarea | |
| 46 | - | id="description" | |
| 47 | - | name="description" | |
| 48 | - | rows="5" | |
| 49 | - | > | |
| 45 | + | <textarea id="description" name="description" rows="5"> | |
| 50 | 46 | {template ?? ""} | |
| 51 | 47 | </textarea> | |
| 52 | 48 | </div> | |
Msrc/views/repos/CommitDetail.tsx
| @@ -118,6 +118,22 @@ export function CommitDetail({ | |||
|---|---|---|---|
| 118 | 118 | </span> | |
| 119 | 119 | </div> | |
| 120 | 120 | )} | |
| 121 | + | {meta.sigStatus !== "none" && ( | |
| 122 | + | <div class="commit-card-meta-row"> | |
| 123 | + | <span class="commit-meta-label">Signature</span> | |
| 124 | + | <span class="commit-meta-value"> | |
| 125 | + | {meta.sigStatus === "good" ? ( | |
| 126 | + | <span class="sig-badge verified"> | |
| 127 | + | verified | |
| 128 | + | </span> | |
| 129 | + | ) : ( | |
| 130 | + | <span class="sig-badge unverified"> | |
| 131 | + | unverified | |
| 132 | + | </span> | |
| 133 | + | )} | |
| 134 | + | </span> | |
| 135 | + | </div> | |
| 136 | + | )} | |
| 121 | 137 | </div> | |
| 122 | 138 | </div> | |
| 123 | 139 | ||
Msrc/views/repos/CommitLog.tsx
| @@ -46,34 +46,59 @@ export function CommitLog({ | |||
|---|---|---|---|
| 46 | 46 | {commits.length === 0 ? ( | |
| 47 | 47 | <p class="text-muted">No commits yet.</p> | |
| 48 | 48 | ) : ( | |
| 49 | - | <ul class="commit-list commit-log"> | |
| 50 | - | {commits.map((c) => ( | |
| 51 | - | <li class="commit-item"> | |
| 52 | - | <div class="commit-main"> | |
| 49 | + | <> | |
| 50 | + | <p class="commit-verify-hint"> | |
| 51 | + | Note: To verify signed commits locally, download the{" "} | |
| 52 | + | <a href="/allowed_signers">allowed signers file</a>{" "} | |
| 53 | + | and run:{" "} | |
| 54 | + | <code class="mono"> | |
| 55 | + | git -c gpg.format=ssh -c | |
| 56 | + | gpg.ssh.allowedSignersFile=allowed_signers | |
| 57 | + | verify-commit <hash> | |
| 58 | + | </code> | |
| 59 | + | </p> | |
| 60 | + | <ul class="commit-list commit-log"> | |
| 61 | + | {commits.map((c) => ( | |
| 62 | + | <li class="commit-item"> | |
| 53 | 63 | <a | |
| 54 | 64 | href={`/${repo.name}/commit/${c.hash}`} | |
| 55 | 65 | class="commit-subject" | |
| 56 | 66 | > | |
| 57 | 67 | {c.subject} | |
| 58 | 68 | </a> | |
| 59 | - | </div> | |
| 60 | - | <div class="commit-meta"> | |
| 61 | - | <span class="commit-author"> | |
| 62 | - | {c.author} | |
| 63 | - | </span> | |
| 64 | - | <a | |
| 65 | - | href={`/${repo.name}/commit/${c.hash}`} | |
| 66 | - | class="commit-hash mono" | |
| 67 | - | > | |
| 68 | - | {c.hash.slice(0, 7)} | |
| 69 | - | </a> | |
| 70 | - | <time class="commit-date" datetime={c.date}> | |
| 71 | - | {formatDateTime(c.date)} | |
| 72 | - | </time> | |
| 73 | - | </div> | |
| 74 | - | </li> | |
| 75 | - | ))} | |
| 76 | - | </ul> | |
| 69 | + | <div class="commit-meta"> | |
| 70 | + | <span class="commit-author"> | |
| 71 | + | {c.author} | |
| 72 | + | </span> | |
| 73 | + | <span class="commit-meta-right"> | |
| 74 | + | {c.sigStatus === "good" && ( | |
| 75 | + | <span class="sig-badge verified"> | |
| 76 | + | verified | |
| 77 | + | </span> | |
| 78 | + | )} | |
| 79 | + | {c.sigStatus === "bad" && ( | |
| 80 | + | <span class="sig-badge unverified"> | |
| 81 | + | unverified | |
| 82 | + | </span> | |
| 83 | + | )} | |
| 84 | + | <a | |
| 85 | + | href={`/${repo.name}/commit/${c.hash}`} | |
| 86 | + | class="commit-hash mono" | |
| 87 | + | > | |
| 88 | + | {c.hash.slice(0, 7)} | |
| 89 | + | </a> | |
| 90 | + | <time | |
| 91 | + | class="commit-date" | |
| 92 | + | datetime={c.date} | |
| 93 | + | > | |
| 94 | + | {formatDateTime(c.date)} | |
| 95 | + | </time> | |
| 96 | + | </span> | |
| 97 | + | </div> | |
| 98 | + | </li> | |
| 99 | + | ))} | |
| 100 | + | </ul> | |
| 101 | + | </> | |
| 77 | 102 | )} | |
| 78 | 103 | {(newerUrl || olderUrl) && ( | |
| 79 | 104 | <nav | |
Msrc/views/repos/RepoSettings.tsx
| @@ -100,7 +100,8 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 100 | 100 | <label for="patch_template"> | |
| 101 | 101 | Patch template{" "} | |
| 102 | 102 | <span class="text-muted"> | |
| 103 | - | (Markdown, prefilled when submitting a new patch) | |
| 103 | + | (Markdown, prefilled when submitting a new | |
| 104 | + | patch) | |
| 104 | 105 | </span> | |
| 105 | 106 | </label> | |
| 106 | 107 | <textarea | |
Mtests/e2e.test.ts
| @@ -2,8 +2,10 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |||
|---|---|---|---|
| 2 | 2 | import { chromium } from 'playwright'; | |
| 3 | 3 | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | 4 | import { $ } from 'bun'; | |
| 5 | + | import { existsSync, readFileSync } from 'node:fs'; | |
| 5 | 6 | import { | |
| 6 | 7 | BASE, | |
| 8 | + | DATA_DIR, | |
| 7 | 9 | ADMIN_PASS, | |
| 8 | 10 | setupTestEnv, | |
| 9 | 11 | spawnServer, | |
| @@ -2363,3 +2365,82 @@ describe('issue and patch templates', () => { | |||
|---|---|---|---|
| 2363 | 2365 | } finally { await page.close(); } | |
| 2364 | 2366 | }); | |
| 2365 | 2367 | }); | |
| 2368 | + | ||
| 2369 | + | // ─── Commit signing ─────────────────────────────────────────────────────────── | |
| 2370 | + | // Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo. | |
| 2371 | + | ||
| 2372 | + | describe('commit signing', () => { | |
| 2373 | + | let adminCtx: BrowserContext; | |
| 2374 | + | ||
| 2375 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2376 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2377 | + | ||
| 2378 | + | test('allowed_signers file is generated at startup', () => { | |
| 2379 | + | const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`; | |
| 2380 | + | expect(existsSync(allowedSignersPath)).toBe(true); | |
| 2381 | + | const content = readFileSync(allowedSignersPath, 'utf8'); | |
| 2382 | + | expect(content).toContain('namespaces="git"'); | |
| 2383 | + | expect(content).toContain('ssh-ed25519'); | |
| 2384 | + | }); | |
| 2385 | + | ||
| 2386 | + | test('merged commit has a gpgsig header', async () => { | |
| 2387 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2388 | + | // Find the patch commit specifically by subject | |
| 2389 | + | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); | |
| 2390 | + | expect(hash).toBeTruthy(); | |
| 2391 | + | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2392 | + | expect(obj).toContain('gpgsig'); | |
| 2393 | + | }); | |
| 2394 | + | ||
| 2395 | + | test('unsigned commits have no gpgsig header', async () => { | |
| 2396 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2397 | + | // Initial commit was created by seedRepo (plain git commit, not hearthforge) | |
| 2398 | + | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); | |
| 2399 | + | expect(hash).toBeTruthy(); | |
| 2400 | + | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2401 | + | expect(obj).not.toContain('gpgsig'); | |
| 2402 | + | }); | |
| 2403 | + | ||
| 2404 | + | test('commit log shows verified badge on signed commit', async () => { | |
| 2405 | + | const page = await adminCtx.newPage(); | |
| 2406 | + | try { | |
| 2407 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 2408 | + | // Find the commit-item for the merged patch by subject text | |
| 2409 | + | const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' }); | |
| 2410 | + | expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2411 | + | } finally { await page.close(); } | |
| 2412 | + | }); | |
| 2413 | + | ||
| 2414 | + | test('commit log shows no sig badge on unsigned commit', async () => { | |
| 2415 | + | const page = await adminCtx.newPage(); | |
| 2416 | + | try { | |
| 2417 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 2418 | + | // Initial commit was not signed via hearthforge | |
| 2419 | + | const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' }); | |
| 2420 | + | expect(await initialItem.locator('.sig-badge').count()).toBe(0); | |
| 2421 | + | } finally { await page.close(); } | |
| 2422 | + | }); | |
| 2423 | + | ||
| 2424 | + | test('commit detail shows verified signature row for signed commit', async () => { | |
| 2425 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2426 | + | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim(); | |
| 2427 | + | const page = await adminCtx.newPage(); | |
| 2428 | + | try { | |
| 2429 | + | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 2430 | + | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 2431 | + | expect(await sigRow.isVisible()).toBe(true); | |
| 2432 | + | expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2433 | + | } finally { await page.close(); } | |
| 2434 | + | }); | |
| 2435 | + | ||
| 2436 | + | test('commit detail shows no signature row for unsigned commit', async () => { | |
| 2437 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`; | |
| 2438 | + | const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim(); | |
| 2439 | + | const page = await adminCtx.newPage(); | |
| 2440 | + | try { | |
| 2441 | + | await page.goto(`${BASE}/my-repo/commit/${hash}`); | |
| 2442 | + | const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' }); | |
| 2443 | + | expect(await sigRow.count()).toBe(0); | |
| 2444 | + | } finally { await page.close(); } | |
| 2445 | + | }); | |
| 2446 | + | }); | |