security: fix git arg injection and harden CI, transport, and uploads

Fixes from code review:

- git: guard all user-supplied refs with --end-of-options to close an
  unauthenticated arbitrary file write (git log --output= via ?after=)
- git smart-HTTP: check exit status (was empty 200 on failure) and drain
  stdout/stderr concurrently to avoid deadlock on large output
- checkPatch: use a throwaway GIT_INDEX_FILE so the patch-apply preview
  can't race/corrupt the shared index during a merge
- ci: drain the image-pull stream to completion (was aborted) and surface
  pull errors; link step exec to the run cancel signal + kill on timeout;
  cap step logs at 2 MB
- auth: rate-limit + bound the passkey create-user endpoint; bound the
  registration application field
- raw files: serve HTML as text/plain and add global nosniff to close a
  same-origin stored-XSS path
- db: run the ci_runs column migration after the table is created so
  pre-CI databases can upgrade
- repos: only set core.bare on first discovery, not on every page view
AuthorKonata <konata@posteo.jp>
Date
Commit4524187d7f4e91d65fd8a55cc95b43ca1f7fc399
Parent1b49a3d
19 files changed, 302 insertions(+), 72 deletions(-)
▾Msrc/app.ts
@@ -117,6 +117,7 @@ export async function createApp(port: number) {
if (response instanceof Response) {
response.headers.set("Content-Security-Policy", CSP);
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
if (config.PUBLIC_HTTPS) {
response.headers.set(
"Strict-Transport-Security",
▾Msrc/constants.ts
@@ -85,6 +85,9 @@ export const ISSUES_PER_PAGE = 20;
export const PATCHES_PER_PAGE = 20;
export const RELEASES_PER_PAGE = 20;
export const CI_RUNS_PER_PAGE = 20;
// Cap a single CI step's captured log so a chatty step can't exhaust server
// RAM (it is buffered in memory) or bloat the ci_steps row.
export const CI_MAX_LOG_BYTES = 2 * 1024 * 1024;
export const BRANCHES_PER_PAGE = 30;
export const TAGS_PER_PAGE = 30;
▾Msrc/db/index.ts
@@ -284,7 +284,11 @@ function runMigrations(s: InstanceType<typeof BunDatabase>) {
}
}
runMigrations(sqlite);
// NOTE: runMigrations() alters ci_runs, so it must run *after* the
// `CREATE TABLE IF NOT EXISTS ci_runs` block below — otherwise upgrading a
// database created before the CI tables existed would ALTER a missing table
// and throw at import. The call is intentionally placed at the end of the
// migration section, not here.
/** Close the current DB and reopen from disk (used by tests after data wipe). */
export function resetDb() {
@@ -342,6 +346,10 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
UNIQUE(repo_id, name)
)`);
// Run column-level migrations now that the CI tables are guaranteed to exist
// (see the note above runMigrations).
runMigrations(sqlite);
// Migration: add allow_user_labels column to repositories if missing
const repoCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(repositories)")
▾Msrc/routes/auth.tsx
@@ -273,7 +273,9 @@ export const authRoutes = new Elysia()
password2: t.Optional(
t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
),
application: t.Optional(t.String()),
application: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
}),
},
)
@@ -292,13 +294,35 @@ export const authRoutes = new Elysia()
// Create account (passkey-only path, called before passkey registration)
.post(
"/auth/passkey/create-user",
async ({ body }) => {
async ({ body, request, server }) => {
if (config.REGISTRATION_TYPE === "disabled") {
return new Response(
JSON.stringify({ error: "Registration is disabled" }),
{ status: 400 },
);
}
// Shares the "register" bucket with the password path so this
// endpoint can't be used to sidestep that limiter (both create a
// real users row).
const ip = getClientIp(request, server);
if (
!checkRateLimit(
ip,
"register",
REGISTRATION_MAX_ATTEMPTS,
REGISTRATION_RATE_WINDOW_MS,
)
) {
return new Response(
JSON.stringify({
error: "Too many registration attempts. Please try again later.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { username, application } = body;
if (!username || !VALID_USERNAME_RE.test(username)) {
return new Response(
@@ -361,8 +385,10 @@ export const authRoutes = new Elysia()
},
{
body: t.Object({
username: t.String(),
application: t.Optional(t.String()),
username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
application: t.Optional(
t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
),
}),
},
)
▾Msrc/routes/git.ts
@@ -184,17 +184,33 @@ async function getRepo(
return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
}
interface GitResult {
ok: boolean;
stdout: Uint8Array;
stderr: string;
}
async function spawnGit(
args: string[],
stdinBytes?: Uint8Array,
): Promise<Uint8Array> {
): Promise<GitResult> {
const proc = Bun.spawn(args, {
stdin: stdinBytes ?? "ignore",
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
// Drain stdout/stderr concurrently with waiting on exit — reading only
// after `exited` can deadlock once git's output exceeds the OS pipe buffer.
const [stdout, stderr, exitCode] = await Promise.all([
Bun.readableStreamToArrayBuffer(proc.stdout),
new Response(proc.stderr).text(),
proc.exited,
]);
return {
ok: exitCode === 0,
stdout: new Uint8Array(stdout),
stderr,
};
}
export const gitRoutes = new Elysia()
@@ -237,10 +253,16 @@ export const gitRoutes = new Elysia()
"--advertise-refs",
repo.repoPath,
]);
if (!refs.ok) {
console.error(
`git ${gitCmd} --advertise-refs failed for ${repo.name}: ${refs.stderr}`,
);
return new Response("Git backend error", { status: 500 });
}
const body = Buffer.concat([
pktLine(`# service=git-${gitCmd}\n`),
PKT_FLUSH,
refs,
refs.stdout,
]);
return new Response(body, {
@@ -270,12 +292,21 @@ export const gitRoutes = new Elysia()
)
return unauthorized();
}
// Oversized bodies are rejected with 413 by the server's
// maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs,
// so the cap surfaces as an explicit error rather than a truncated read.
const body = new Uint8Array(await request.arrayBuffer());
const result = await spawnGit(
["git", "upload-pack", "--stateless-rpc", repo.repoPath],
body,
);
return new Response(result, {
if (!result.ok) {
console.error(
`git upload-pack failed for ${repo.name}: ${result.stderr}`,
);
return new Response("Git backend error", { status: 500 });
}
return new Response(result.stdout, {
headers: {
"Content-Type": "application/x-git-upload-pack-result",
"Cache-Control": "no-cache",
@@ -292,16 +323,25 @@ export const gitRoutes = new Elysia()
return unauthorized();
const repo = await getRepo(params.repo);
if (!repo) return new Response("Not Found", { status: 404 });
// Oversized pushes are rejected with 413 by the server's
// maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs,
// so the cap surfaces as an explicit error rather than a truncated read.
const body = new Uint8Array(await request.arrayBuffer());
const refUpdates = parseRefUpdates(body);
const result = await spawnGit(
["git", "receive-pack", "--stateless-rpc", repo.repoPath],
body,
);
if (!result.ok) {
console.error(
`git receive-pack failed for ${repo.name}: ${result.stderr}`,
);
return new Response("Git backend error", { status: 500 });
}
invalidateRefCache(repo.name);
// Trigger CI in background — don't block the git push response
triggerCiForPush(repo.name, refUpdates).catch(() => {});
return new Response(result, {
return new Response(result.stdout, {
headers: {
"Content-Type": "application/x-git-receive-pack-result",
"Cache-Control": "no-cache",
▾Msrc/routes/repos.tsx
@@ -164,6 +164,21 @@ async function mimeForContent(
: "text/plain; charset=utf-8";
}
// A repo file opened directly via /raw is served from the forge's own origin.
// HTML would render as a document there and, despite our CSP, could load a
// same-origin `<script src>` pointing at another raw file — a stored-XSS path
// through the normal patch-merge flow. Serve HTML as plain text so it can't
// execute; every other type keeps its real MIME so media previews and
// downloads still work. Paired with `X-Content-Type-Options: nosniff` (set
// globally) so a text/plain body can't be sniffed back into HTML.
function rawServeContentType(contentType: string): string {
const base = contentType.split(";")[0]!.trim().toLowerCase();
if (base === "text/html" || base === "application/xhtml+xml") {
return "text/plain; charset=utf-8";
}
return contentType;
}
const README_NAMES = ["README.md", "readme.md", "README", "readme"];
async function readReadme(
@@ -696,7 +711,9 @@ export const repoRoutes = new Elysia()
const head = firstChunk
? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES))
: Buffer.alloc(0);
const contentType = await mimeForContent(filename, head);
const contentType = rawServeContentType(
await mimeForContent(filename, head),
);
const rangeHeader = request.headers.get("Range");
const fullProc = Bun.spawn(blobArgs, {
@@ -1106,10 +1123,7 @@ export const repoRoutes = new Elysia()
try {
renameSync(fromPath, toPath);
} catch (err) {
console.error(
`rename ${fromPath} -> ${toPath} failed`,
err,
);
console.error(`rename ${fromPath} -> ${toPath} failed`, err);
return back("Failed to rename repository on disk.");
}
▾Msrc/services/ci.ts
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import config from "../config.ts";
import { paths } from "../constants.ts";
import { CI_MAX_LOG_BYTES, paths } from "../constants.ts";
import { db } from "../db/index.ts";
import { repoPath } from "./git.ts";
@@ -264,8 +264,31 @@ async function pullImage(image: string): Promise<void> {
`/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`,
{ method: "POST" },
);
// Consume body to completion
await resp.body?.cancel();
if (!resp.ok) {
const detail = (await resp.text().catch(() => "")).trim();
throw new Error(
`Failed to pull image ${image}: HTTP ${resp.status}${detail ? ` ${detail}` : ""}`,
);
}
// The /images/create stream must be read to the end — the pull only
// completes when the stream does. Cancelling it (the previous behavior)
// aborted the pull, so createContainer could race a not-yet-present image.
// Each line is a JSON progress object; a trailing {"error": …} means the
// pull failed despite the HTTP 200.
const body = await resp.text();
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
let obj: { error?: string } | null = null;
try {
obj = JSON.parse(trimmed);
} catch {
continue; // non-JSON progress line — ignore
}
if (obj?.error) {
throw new Error(`Failed to pull image ${image}: ${obj.error}`);
}
}
}
function parseMemoryBytes(s: string): number {
@@ -417,6 +440,22 @@ async function execInContainer(
});
let log = "";
let truncated = false;
// Bound the buffered log: stop appending once we hit the cap (and note it
// once) so a runaway step can't exhaust RAM or make each partial-flush
// rewrite an ever-growing row.
const appendLog = (text: string) => {
if (truncated || !text) return;
const room = CI_MAX_LOG_BYTES - log.length;
if (text.length <= room) {
log += text;
} else {
log += text.slice(0, Math.max(0, room));
log += `\n[log truncated at ${CI_MAX_LOG_BYTES} bytes]\n`;
truncated = true;
}
};
if (onPartialLog && startResp.body) {
const reader = startResp.body.getReader();
let buf = new Uint8Array(0);
@@ -430,18 +469,19 @@ async function execInContainer(
buf = merged;
const { text, remaining } = parseMuxFrames(buf);
buf = remaining;
log += text;
if (Date.now() - lastSave >= 2000) {
appendLog(text);
// Once truncated the log no longer changes, so stop re-flushing it.
if (!truncated && Date.now() - lastSave >= 2000) {
await onPartialLog(log);
lastSave = Date.now();
}
}
const { text } = parseMuxFrames(buf);
log += text;
appendLog(text);
} else {
const bodyBytes = new Uint8Array(await startResp.arrayBuffer());
const { text } = parseMuxFrames(bodyBytes);
log = text;
appendLog(text);
}
// Get exit code
@@ -844,6 +884,11 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
const stepTimeout =
step.timeout ?? cfg.timeout ?? config.CI_DEFAULT_TIMEOUT;
const timeoutSignal = AbortSignal.timeout(stepTimeout * 1000);
// Abort the exec stream on either a run cancellation or the
// per-step timeout. Docker has no per-exec kill, so on abort we
// force-remove the container (below), which kills the command
// still running inside it.
const stepSignal = AbortSignal.any([signal, timeoutSignal]);
try {
const { log, exitCode } = await execInContainer(
@@ -851,7 +896,7 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
[...shell, command],
cfg.work_dir,
envArray,
timeoutSignal,
stepSignal,
async (partial) => {
await db
.updateTable("ci_steps")
@@ -868,9 +913,20 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
runFailed = true;
}
} catch (err) {
stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
// A run cancellation is reported as "cancelled" by the outer
// catch — don't relabel it as a step failure here.
if (signal.aborted) throw err;
stepStatus = "failure";
runFailed = true;
if (timeoutSignal.aborted) {
stepLog = `Step timed out after ${stepTimeout}s\n`;
// Kill the container now so the timed-out command stops
// immediately rather than lingering until cleanup.
await removeContainer(containerId);
containerId = "";
} else {
stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
}
}
}
▾Msrc/services/git.ts
@@ -65,7 +65,8 @@ export async function validateCommit(
): Promise<boolean> {
const p = repoPath(repoName);
try {
const out = await $`git -C ${p} cat-file -t ${hash}`.text();
const out =
await $`git -C ${p} cat-file -t --end-of-options ${hash}`.text();
return out.trim() === "commit";
} catch {
return false;
@@ -90,6 +91,7 @@ export async function archiveRepo(
"archive",
"--format=zip",
`--output=${path.join(outDir, `${base}.zip`)}`,
"--end-of-options",
ref,
],
{ signal, env: gitEnv },
@@ -104,6 +106,7 @@ export async function archiveRepo(
"archive",
"--format=tar.gz",
`--output=${path.join(outDir, `${base}.tar.gz`)}`,
"--end-of-options",
ref,
],
{ signal, env: gitEnv },
@@ -113,7 +116,15 @@ export async function archiveRepo(
try {
const tar = Bun.spawn(
["git", "-C", p, "archive", "--format=tar", ref],
[
"git",
"-C",
p,
"archive",
"--format=tar",
"--end-of-options",
ref,
],
{ signal, env: gitEnv, stdout: "pipe" },
);
const zst = Bun.spawn(
@@ -299,8 +310,12 @@ export const git = {
`gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
];
try {
// `--end-of-options` before the ref stops a user-supplied ref that
// begins with `-` from being parsed as a git option (e.g. `--output=`,
// which would write to an arbitrary file). All real options must
// therefore precede it.
const out =
await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
await $`git ${sigArgs} -C ${p} log --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip} --end-of-options ${ref}`.text();
return parseLog(out);
} catch {
return [];
@@ -321,11 +336,20 @@ export const git = {
p,
"ls-tree",
"--long",
"--end-of-options",
ref,
"--",
`${subpath}/`,
]
: ["git", "-C", p, "ls-tree", "--long", ref];
: [
"git",
"-C",
p,
"ls-tree",
"--long",
"--end-of-options",
ref,
];
const out = await $`${args}`.text();
const entries = parseLsTree(out);
if (subpath) {
@@ -352,7 +376,7 @@ export const git = {
const p = repoPath(name);
try {
const buf =
await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer();
return Buffer.from(buf);
} catch {
return null;
@@ -362,7 +386,7 @@ export const git = {
async diff(name: string, sha: string): Promise<string> {
const p = repoPath(name);
try {
return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root --end-of-options ${sha}`.text();
} catch {
return "";
}
@@ -372,7 +396,8 @@ export const git = {
if (/^0+$/.test(hash)) return 0;
const p = repoPath(name);
try {
const out = await $`git -C ${p} cat-file -s ${hash}`.text();
const out =
await $`git -C ${p} cat-file -s --end-of-options ${hash}`.text();
return parseInt(out.trim(), 10) || 0;
} catch {
return 0;
@@ -522,7 +547,7 @@ export const git = {
const p = repoPath(name);
try {
const out =
await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
await $`git -C ${p} cat-file -s --end-of-options ${`${ref}:${filePath}`}`.text();
return parseInt(out.trim(), 10);
} catch {
return null;
@@ -535,13 +560,20 @@ export const git = {
): Promise<{ clean: boolean; output: string }> {
const p = repoPath(name);
const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
// Use a throwaway index (GIT_INDEX_FILE) so this read-only preview never
// mutates — nor races a concurrent applyPatch/editFile on — the repo's
// shared index. Without it, this GET-triggered check could reset the
// index mid-merge and silently drop the patch being written.
const tmpIndex = `/tmp/hf-index-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const idxEnv = { ...gitEnv, GIT_INDEX_FILE: tmpIndex };
try {
await Bun.write(tmpFile, patchContent);
// Bare repos have no working tree; populate the index from HEAD so we can
// check against git objects (--cached) rather than the filesystem.
await $`git -C ${p} read-tree HEAD`.quiet();
await $`git -C ${p} read-tree HEAD`.env(idxEnv).quiet();
const result =
await $`git -C ${p} apply --check --cached ${tmpFile}`
.env(idxEnv)
.quiet()
.nothrow();
return {
@@ -551,7 +583,7 @@ export const git = {
} catch (e) {
return { clean: false, output: String(e) };
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
await $`rm -f ${tmpFile} ${tmpIndex}`.quiet().nothrow();
}
},
@@ -839,14 +871,14 @@ export const git = {
];
const result =
message !== undefined
? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
? await $`git ${sigArgs} -C ${p} tag -s -m ${message} --end-of-options ${tagName} ${ref}`
.env({
...gitEnv,
GIT_COMMITTER_NAME: taggerName!,
GIT_COMMITTER_EMAIL: taggerEmail!,
})
.nothrow()
: await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
: await $`git -C ${p} tag --end-of-options ${tagName} ${ref}`.nothrow();
if (result.exitCode === 0) {
invalidateRefCache(repoName);
return "ok";
@@ -962,7 +994,8 @@ export const git = {
async resolveRef(name: string, ref: string): Promise<string | null> {
const p = repoPath(name);
try {
const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
const out =
await $`git -C ${p} rev-parse --verify --end-of-options ${ref}`.text();
return out.trim() || null;
} catch {
return null;
@@ -989,8 +1022,8 @@ export const git = {
];
try {
const [metaOut, msgOut] = await Promise.all([
$`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(),
$`git -C ${p} log --format=%B -1 ${sha}`.text(),
$`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? --end-of-options ${sha}`.text(),
$`git -C ${p} log --format=%B -1 --end-of-options ${sha}`.text(),
]);
const parts = metaOut.trim().split("\x1f");
const fullMsg = msgOut.trimEnd();
▾Msrc/services/repoSync.ts
@@ -93,9 +93,13 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
.selectAll()
.where("name", "=", name)
.executeTakeFirst();
await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
if (existing) return existing;
// First time this repo is seen (pushed externally, manually imported, or
// freshly created): make sure git treats it as bare before we record and
// serve it. Doing this only on discovery — not on every read — keeps repo
// page views free of a per-request subprocess spawn and config write.
await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
const branch = await git.defaultBranch(name);
const now = new Date().toISOString();
return await db
▾Msrc/views/DiffView.tsx
@@ -151,7 +151,9 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
<>
{files.length > 0 && (
<div class="commit-stats-bar">
<span class="commit-stats-text" safe>{changedStr}</span>
<span class="commit-stats-text" safe>
{changedStr}
</span>
</div>
)}
@@ -192,12 +194,18 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
>
{sl}
</span>
<span class="diff-file-path mono" safe>
<span
class="diff-file-path mono"
safe
>
{displayPath}
</span>
{f.status === "renamed" &&
f.oldPath !== f.newPath && (
<span class="diff-rename-arrow" safe>
<span
class="diff-rename-arrow"
safe
>
← {f.oldPath}
</span>
)}
▾Msrc/views/ReactionBar.tsx
@@ -43,7 +43,7 @@ export function ReactionBar({
disabled={!user}
safe
>
{unsafeEmoji + " " + r.count}
{`${unsafeEmoji} ${r.count}`}
</button>
</form>
);
▾Msrc/views/Settings.tsx
@@ -311,7 +311,10 @@ export function Settings({
<span class="ssh-key-name" safe>
{key.name}
</span>
<span class="passkey-date ssh-key-fingerprint" safe>
<span
class="passkey-date ssh-key-fingerprint"
safe
>
{key.fingerprint}
</span>
<span class="passkey-date" safe>
@@ -420,14 +423,20 @@ export function Settings({
<strong safe>
{u.username}
</strong>
<span class="queue-item-date" safe>
<span
class="queue-item-date"
safe
>
{formatDateTime(
u.created_at,
)}
</span>
</div>
{!!u.register_application && (
<p class="queue-item-answer" safe>
<p
class="queue-item-answer"
safe
>
{u.register_application}
</p>
)}
▾Msrc/views/ci/CiHistory.tsx
@@ -100,7 +100,10 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
<div class="ci-help-section">
<h4 class="ci-help-section-title">Status badge</h4>
<p class="ci-help-badge-desc">Embed in your README:</p>
<code class="ci-help-badge-code" safe>{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
<code
class="ci-help-badge-code"
safe
>{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
<h4
class="ci-help-section-title"
style="margin-top: var(--space-4)"
@@ -145,9 +148,7 @@ export function CiHistory({
);
return (
<Layout user={user} title={`Pipelines — ${repo.name}`}>
{isRunning ? (
<meta http-equiv="refresh" content="4" />
) : null}
{isRunning ? <meta http-equiv="refresh" content="4" /> : null}
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
@@ -244,7 +245,8 @@ export function CiHistory({
</span>
{!!run.triggered_by_username && (
<span class="text-muted" safe>
by {run.triggered_by_username}
by{" "}
{run.triggered_by_username}
</span>
)}
{run.artifact_count > 0 && (
@@ -258,7 +260,10 @@ export function CiHistory({
)}
{!!run.started_at &&
!!run.finished_at && (
<span class="text-muted" safe>
<span
class="text-muted"
safe
>
{duration(
run.started_at,
run.finished_at,
▾Msrc/views/ci/CiRunDetail.tsx
@@ -137,7 +137,11 @@ export function CiRunDetail({
{duration(run.started_at, run.finished_at)}
</span>
)}
<time datetime={run.created_at} class="text-muted" safe>
<time
datetime={run.created_at}
class="text-muted"
safe
>
{formatDateTime(run.created_at)}
</time>
</div>
@@ -222,14 +226,18 @@ export function CiRunDetail({
<span class="ci-step-name" safe>
{step.name}
</span>
{!!step.started_at && !!step.finished_at && (
<span class="ci-step-duration text-muted" safe>
{duration(
step.started_at,
step.finished_at,
)}
</span>
)}
{!!step.started_at &&
!!step.finished_at && (
<span
class="ci-step-duration text-muted"
safe
>
{duration(
step.started_at,
step.finished_at,
)}
</span>
)}
</summary>
{step.log ? (
<pre class="ci-step-log" safe>
@@ -258,7 +266,10 @@ export function CiRunDetail({
>
{artifact.filename}
</a>
<span class="ci-artifact-size text-muted" safe>
<span
class="ci-artifact-size text-muted"
safe
>
{formatBytes(artifact.size)}
</span>
</li>
▾Msrc/views/releases/ReleaseList.tsx
@@ -27,7 +27,9 @@ function NotesPreview({ notes }: { notes: string }) {
</div>
<span class="notes-toggle-label" />
</summary>
<div class="notes-full markdown-body">{renderMarkdown(notes) as "safe"}</div>
<div class="notes-full markdown-body">
{renderMarkdown(notes) as "safe"}
</div>
</details>
);
}
@@ -97,7 +99,10 @@ export function ReleaseList({
{release.tag_name}
</a>
)}
<time datetime={release.created_at} safe>
<time
datetime={release.created_at}
safe
>
{formatDate(release.created_at)}
</time>
</div>
▾Msrc/views/repos/CommitDetail.tsx
@@ -109,7 +109,10 @@ export function CommitDetail({
)}
<div class="commit-card-meta-row">
<span class="commit-meta-label">Commit</span>
<code class="commit-meta-value commit-sha-full mono" safe>
<code
class="commit-meta-value commit-sha-full mono"
safe
>
{meta.hash}
</code>
</div>
▾Msrc/views/repos/FileBlob.tsx
@@ -124,7 +124,9 @@ export function FileBlob({
</div>
<div class="file-blob-body">
{markdownHtml ? (
<div class="markdown-body">{markdownHtml as "safe"}</div>
<div class="markdown-body">
{markdownHtml as "safe"}
</div>
) : view.type === "inline" ? (
<div class="shiki-wrapper">{view.html as "safe"}</div>
) : view.type === "media" ? (
▾Msrc/views/repos/RepoHome.tsx
@@ -124,7 +124,9 @@ git push origin main`}</code>
</a>
)}
</div>
<div class="markdown-body">{readmeHtml as "safe"}</div>
<div class="markdown-body">
{readmeHtml as "safe"}
</div>
</div>
)}
</>
▾Msrc/views/repos/RepoSettings.tsx
@@ -308,9 +308,9 @@ export function RepoSettings({
<div class="danger-item-info">
<strong>Rename this repository</strong>
<p class="text-muted">
Changing the name breaks existing clone
URLs and links to this repo. Collaborators
will need to update their remotes.
Changing the name breaks existing clone URLs
and links to this repo. Collaborators will
need to update their remotes.
</p>
</div>
<form