import { createServerFn } from "@tanstack/react-start"; import { resolveMentions } from "~/server/mentions"; import { limitWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { placeholders } from "~/server/db"; import { repoDb } from "~/server/scoped-db"; import { forgetLocator, registerLocator } from "~/server/scoped-db/locators"; import { scopedId } from "~/server/scoped-db/ids"; import { requireRepoAccess, requireRepoMutation } from "~/server/repos"; import { listRefs, resolveRevision } from "~/server/git/refs"; import { assertVisibleRevision } from "~/server/git/reachable"; import { createTag, deleteRef, isValidRefName, PushRejectedError } from "~/server/git/writer"; import { loadProtections, tagProtectionFor, tagUpdateViolation } from "~/server/protection"; import { assertBelowCap, CAPS } from "~/server/limits"; import { signatureNow } from "~/server/git/objects"; import { ZERO_SHA } from "~/server/git/store"; import { triggerPush } from "~/server/events/push-trigger"; import { emitNotification, emitWebhook, publicUrl, recordActivity } from "~/server/events/emit"; import { scheduleWorkflows } from "~/server/ci/scheduler"; import { accountOrGhost, accountsById } from "~/server/people"; import { renderMarkdown } from "~/server/markdown"; import { deleteObjects } from "~/server/storage"; import { badRequest, conflict, forbidden, notFoundError } from "~/server/errors"; import { markdownBody, repoRef } from "~/lib/validation"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; type ReleaseRow = { id: string; tag: string; name: string; body: string; author_id: string; target_sha: string; draft: number; prerelease: number; created_at: number; published_at: number | null; }; async function publishEvents(repo: RepoRow, release: ReleaseRow, viewer: Viewer) { const url = publicUrl(`/${repo.owner_name}/${repo.name}/releases/tag/${encodeURIComponent(release.tag)}`); await emitWebhook(repo.id, "release", { action: "published", release: { tag_name: release.tag, name: release.name, body: release.body, prerelease: release.prerelease === 1, html_url: url, }, sender: { login: viewer.name, id: viewer.id }, }); await recordActivity(viewer.id, repo.id, "release", { tag: release.tag, name: release.name }); await emitNotification({ repoId: repo.id, issueId: null, actorId: viewer.id, reason: "release", title: `${repo.owner_name}/${repo.name}: released ${release.name || release.tag}`, url, mentionText: null, directRecipients: [], }); await scheduleWorkflows(repo, { event: "release", action: "published", ref: `refs/tags/${release.tag}`, sha: release.target_sha, actorId: viewer.id, actorName: viewer.name, title: `Release ${release.name}`, prNumber: null, headRef: null, baseRef: null, changedPaths: null, inputs: {}, workflowPath: null, sourceRepo: repo, }); } export const listReleases = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ tag: z.string().optional() })) .handler(async ({ data, context }) => { const { repo, permission } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const canWrite = permission === "write" || permission === "admin"; const rows = await repoDb(repo.id).all( `SELECT * FROM releases WHERE repo_id = ? ${data.tag ? "AND tag = ?" : ""} ${canWrite ? "" : "AND draft = 0"} ORDER BY COALESCE(published_at, created_at) DESC LIMIT 50`, ...(data.tag ? [repo.id, data.tag] : [repo.id]), ); const assets = rows.length ? await repoDb(repo.id).all<{ id: string; release_id: string; name: string; size: number; download_count: number; }>( `SELECT id, release_id, name, size, download_count FROM release_assets WHERE release_id IN (${placeholders(rows.length)}) ORDER BY name`, ...rows.map((row) => row.id), ) : []; const [people, mentions] = await Promise.all([ accountsById(rows.map((row) => row.author_id)), resolveMentions(rows.map((row) => row.body)), ]); return { canWrite, releases: rows.map((row) => ({ id: row.id, tag: row.tag, name: row.name, body: row.body, html: renderMarkdown( row.body, { owner: repo.owner_name, repo: repo.name, ref: row.tag, basePath: "" }, mentions, ), author: accountOrGhost(people, row.author_id), targetSha: row.target_sha, draft: row.draft === 1, prerelease: row.prerelease === 1, createdAt: row.created_at, publishedAt: row.published_at, assets: assets.filter((asset) => asset.release_id === row.id), })), }; }); /** Release tags are written outside the git proxy, so they must apply the same `refs/tags/` rules (GIT-A3). */ async function assertTagChangeAllowed( repo: RepoRow, permission: Permission, tag: string, change: "create" | "delete", ): Promise { if (repo.is_archived === 1) throw forbidden("This repository is archived"); const ref = `refs/tags/${tag}`; const violation = tagUpdateViolation(tagProtectionFor(await loadProtections(repo.id), ref), permission, change); if (violation) throw forbidden(violation); } export const createRelease = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ tag: z.string().min(1).max(200), target: z.string().min(1), name: z.string().max(200), body: markdownBody, draft: z.boolean(), prerelease: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); if (!isValidRefName(data.tag)) throw badRequest("Invalid tag name"); if (await repoDb(repo.id).first("SELECT 1 FROM releases WHERE repo_id = ? AND tag = ?", repo.id, data.tag)) throw conflict("A release for that tag exists"); assertBelowCap( ( await repoDb(repo.id).first<{ count: number }>( "SELECT COUNT(*) AS count FROM releases WHERE repo_id = ?", repo.id, ) )?.count ?? 0, CAPS.releasesPerRepo, "releases", ); const refs = await listRefs(repo); const existingTag = refs.tags.find((tag) => tag.name === data.tag); let targetSha = existingTag?.sha ?? null; if (!targetSha) { const target = resolveRevision(refs, data.target); if (!target) throw badRequest("Target branch or commit not found"); await assertVisibleRevision(repo, target); await assertTagChangeAllowed(repo, permission, data.tag, "create"); targetSha = target.sha; await createTag({ repo, name: data.tag, targetSha, message: null, tagger: signatureNow(viewer.displayName ?? viewer.name, viewer.email), }).catch((error) => { if (error instanceof PushRejectedError) throw conflict(error.message); throw error; }); await triggerPush({ source: "internal", repoId: repo.id, artifactsName: repo.artifacts_name, ref: `refs/tags/${data.tag}`, before: ZERO_SHA, after: targetSha, pusherId: viewer.id, }); } const now = Date.now(); const release: ReleaseRow = { id: scopedId(repo.id), tag: data.tag, name: data.name || data.tag, body: data.body, author_id: viewer.id, target_sha: targetSha, draft: data.draft ? 1 : 0, prerelease: data.prerelease ? 1 : 0, created_at: now, published_at: data.draft ? null : now, }; await repoDb(repo.id).run( `INSERT INTO releases (id, repo_id, tag, name, body, author_id, target_sha, draft, prerelease, created_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, release.id, repo.id, release.tag, release.name, release.body, release.author_id, release.target_sha, release.draft, release.prerelease, release.created_at, release.published_at, ); await registerLocator("release", release.id, repo.id); if (!data.draft) await publishEvents(repo, release, viewer); return { id: release.id, tag: release.tag }; }); export const updateRelease = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string(), name: z.string().max(200), body: markdownBody, draft: z.boolean(), prerelease: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const release = await repoDb(repo.id).first( "SELECT * FROM releases WHERE id = ? AND repo_id = ?", data.id, repo.id, ); if (!release) throw notFoundError("Release not found"); const publishing = release.draft === 1 && !data.draft; const publishedAt = publishing ? Date.now() : release.published_at; await repoDb(repo.id).run( "UPDATE releases SET name = ?, body = ?, draft = ?, prerelease = ?, published_at = ? WHERE id = ?", data.name || release.tag, data.body, data.draft ? 1 : 0, data.prerelease ? 1 : 0, publishedAt, release.id, ); if (publishing) await publishEvents(repo, { ...release, name: data.name || release.tag, body: data.body }, viewer); return { ok: true }; }); export const deleteRelease = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string(), deleteTag: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const release = await repoDb(repo.id).first( "SELECT * FROM releases WHERE id = ? AND repo_id = ?", data.id, repo.id, ); if (!release) throw notFoundError("Release not found"); if (data.deleteTag) await assertTagChangeAllowed(repo, permission, release.tag, "delete"); const assets = await repoDb(repo.id).all<{ id: string; r2_key: string }>( "SELECT id, r2_key FROM release_assets WHERE release_id = ?", release.id, ); await deleteObjects(assets.map((asset) => asset.r2_key)); await repoDb(repo.id).run("DELETE FROM releases WHERE id = ?", release.id); await Promise.all([ forgetLocator("release", release.id), ...assets.map((asset) => forgetLocator("release_asset", asset.id)), ]); if (data.deleteTag) { const tag = (await listRefs(repo)).tags.find((candidate) => candidate.name === release.tag); if (tag) { await deleteRef(repo, `refs/tags/${release.tag}`, tag.sha).catch((error) => { if (error instanceof PushRejectedError) throw conflict(error.message); throw error; }); await triggerPush({ source: "internal", repoId: repo.id, artifactsName: repo.artifacts_name, ref: `refs/tags/${release.tag}`, before: tag.sha, after: ZERO_SHA, pusherId: viewer.id, }); } } return { ok: true }; }); export const deleteReleaseAsset = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoMutation(data.owner, data.repo, context.viewer, "write"); const asset = await repoDb(repo.id).first<{ id: string; r2_key: string }>( "SELECT a.id, a.r2_key FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = ? AND r.repo_id = ?", data.id, repo.id, ); if (!asset) throw notFoundError("Asset not found"); await deleteObjects([asset.r2_key]); await repoDb(repo.id).run("DELETE FROM release_assets WHERE id = ?", asset.id); await forgetLocator("release_asset", asset.id); return { ok: true }; });