allow creating annotated tags when creating releases

AuthorKonata <konata@posteo.jp>
Date
Commitf2122aa00dc7af0914c2649cd90f35bf37f9871c
Parentbcd74c1
10 files changed, 241 insertions(+), 159 deletions(-)
▾Msrc/db/index.ts
@@ -116,10 +116,9 @@ interface SshKeyTable {
interface ReleaseTable {
id: Generated<number>;
repo_id: number;
tag_name: string;
name: string | null;
tag_name: string | null;
name: string;
notes: string | null;
commit_hash: string;
include_source_code: number;
created_at: string;
}
▾Msrc/db/schema.sql
@@ -114,10 +114,9 @@ CREATE TABLE IF NOT EXISTS ssh_keys (
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
tag_name TEXT NOT NULL,
name TEXT,
tag_name TEXT,
name TEXT NOT NULL,
notes TEXT,
commit_hash TEXT NOT NULL,
include_source_code INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(repo_id, tag_name)
▾Msrc/routes/releases.tsx
@@ -4,7 +4,7 @@ import { Elysia, t } from "elysia";
import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { archiveRepo, validateCommit } from "../services/git.ts";
import { archiveRepo, git } from "../services/git.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { NewRelease } from "../views/releases/NewRelease.tsx";
import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx";
@@ -120,91 +120,117 @@ export const releasesRoutes = new Elysia()
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const tagName = body.tag_name?.trim() ?? "";
const commitHash = body.commit_hash?.trim() ?? "";
const name = body.name?.trim() ?? "";
const createTag = body.create_tag === "on";
const tagName = createTag ? (body.tag_name?.trim() ?? "") : null;
const revision = createTag ? (body.revision?.trim() ?? "") : null;
const formValues = {
...body,
create_tag: createTag,
include_source_code: body.include_source_code === "on",
};
if (!tagName) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name is required"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
if (tagName.includes("/")) {
if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name must not contain slashes"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
error="You must set your git name and email in Settings before creating a tagged release"
values={formValues}
/>,
);
}
if (!commitHash) {
if (!name) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Commit hash is required"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
error="Release title is required"
values={formValues}
/>,
);
}
const validCommit = await validateCommit(repo.name, commitHash);
if (!validCommit) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Invalid commit hash — no matching commit found in this repository"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
if (createTag) {
if (!tagName) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name is required when creating a git tag"
values={formValues}
/>,
);
}
if (tagName.includes("/")) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name must not contain slashes"
values={formValues}
/>,
);
}
if (!revision) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Revision is required when creating a git tag"
values={formValues}
/>,
);
}
}
// Check for duplicate tag
const existing = await db
.selectFrom("releases")
.select("id")
.where("repo_id", "=", repo.id)
.where("tag_name", "=", tagName)
.executeTakeFirst();
if (existing) {
return html(
<NewRelease
user={user!}
repo={repo}
error={`A release with tag "${tagName}" already exists`}
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
// Create git tag
if (createTag && tagName && revision) {
const notes = body.notes?.trim() || null;
const tagMessage = notes ? `${name}\n\n${notes}` : name;
const tagResult = await git.createTag(
repo.name,
tagName,
revision,
tagMessage,
user!.git_name!,
user!.git_email!,
);
if (tagResult === "already_exists") {
return html(
<NewRelease
user={user!}
repo={repo}
error={`Git tag "${tagName}" already exists in this repository`}
values={formValues}
/>,
);
}
if (tagResult === "bad_ref") {
return html(
<NewRelease
user={user!}
repo={repo}
error={`"${revision}" is not a valid revision in this repository`}
values={formValues}
/>,
);
}
if (tagResult === "error") {
return html(
<NewRelease
user={user!}
repo={repo}
error="Failed to create git tag"
values={formValues}
/>,
);
}
}
const includeSource = body.include_source_code === "on";
const includeSource =
body.include_source_code === "on" && createTag && !!tagName;
const now = new Date().toISOString();
// Collect uploaded file data before opening the transaction so we
@@ -243,9 +269,8 @@ export const releasesRoutes = new Elysia()
.values({
repo_id: repo.id,
tag_name: tagName,
name: body.name?.trim() || null,
name,
notes: body.notes?.trim() || null,
commit_hash: commitHash,
include_source_code: includeSource ? 1 : 0,
created_at: now,
})
@@ -315,7 +340,7 @@ export const releasesRoutes = new Elysia()
try {
await archiveRepo(
repo.name,
commitHash,
tagName!,
repo.name,
sourceDir,
controller.signal,
@@ -343,10 +368,11 @@ export const releasesRoutes = new Elysia()
},
{
body: t.Object({
create_tag: t.Optional(t.String()),
tag_name: t.Optional(t.String()),
revision: t.Optional(t.String()),
name: t.Optional(t.String()),
notes: t.Optional(t.String()),
commit_hash: t.Optional(t.String()),
include_source_code: t.Optional(t.String()),
files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
}),
@@ -389,7 +415,7 @@ export const releasesRoutes = new Elysia()
}[] = [];
let sourceArchivesPending = false;
if (release.include_source_code) {
const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`;
const base = `${repo.name}-${release.tag_name}`;
const sourceDir = path.join(
RELEASES_DIR,
String(release.id),
▾Msrc/services/git.ts
@@ -59,13 +59,13 @@ export async function validateCommit(
export async function archiveRepo(
repoName: string,
commitHash: string,
ref: string,
slug: string,
outDir: string,
signal?: AbortSignal,
): Promise<void> {
const p = repoPath(repoName);
const base = `${slug}-${commitHash.slice(0, 8)}`;
const base = `${slug}-${ref}`;
const zip = Bun.spawn(
[
@@ -75,7 +75,7 @@ export async function archiveRepo(
"archive",
"--format=zip",
`--output=${path.join(outDir, `${base}.zip`)}`,
commitHash,
ref,
],
{ signal, env: gitEnv },
);
@@ -89,7 +89,7 @@ export async function archiveRepo(
"archive",
"--format=tar.gz",
`--output=${path.join(outDir, `${base}.tar.gz`)}`,
commitHash,
ref,
],
{ signal, env: gitEnv },
);
@@ -98,7 +98,7 @@ export async function archiveRepo(
try {
const tar = Bun.spawn(
["git", "-C", p, "archive", "--format=tar", commitHash],
["git", "-C", p, "archive", "--format=tar", ref],
{ signal, env: gitEnv, stdout: "pipe" },
);
const zst = Bun.spawn(
@@ -417,6 +417,39 @@ export const git = {
});
},
async createTag(
repoName: string,
tagName: string,
ref: string,
message?: string,
taggerName?: string,
taggerEmail?: string,
): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
return withRepoLock(repoName, async () => {
const p = repoPath(repoName);
const result =
message !== undefined
? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}`
.env({
...gitEnv,
GIT_COMMITTER_NAME: taggerName!,
GIT_COMMITTER_EMAIL: taggerEmail!,
})
.nothrow()
: await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
if (result.exitCode === 0) return "ok";
const stderr = result.stderr.toString();
if (stderr.includes("already exists")) return "already_exists";
if (
stderr.includes("not a valid object name") ||
stderr.includes("unknown revision") ||
stderr.includes("ambiguous argument")
)
return "bad_ref";
return "error";
});
},
async setHead(name: string, branch: string): Promise<void> {
const p = repoPath(name);
await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
▾Msrc/styles/main.css
@@ -706,6 +706,7 @@
font-size: var(--text-xs);
font-weight: 500;
border: 1px solid;
text-decoration: none;
}
.badge-private {
color: var(--color-warning);
▾Msrc/views/releases/NewRelease.tsx
@@ -9,15 +9,19 @@ interface NewReleaseProps {
repo: RepositoryRow;
error?: string;
values?: {
create_tag?: boolean;
tag_name?: string;
revision?: string;
name?: string;
notes?: string;
commit_hash?: string;
include_source_code?: boolean;
};
}
export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
const showTagFields = values?.create_tag ?? false;
const defaultRevision =
values?.revision !== undefined ? values.revision : repo.default_branch;
return (
<Layout user={user} title={`New Release — ${repo.name}`}>
<div class="container">
@@ -25,53 +29,75 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
<RepoNav repo={repo} active="releases" user={user} />
<div class="form-page">
<h2 class="page-title">New Release</h2>
{error && <div class="flash flash-error">{error}</div>}
{error && <div class="form-error">{error}</div>}
<form
method="post"
action={`/${repo.name}/releases`}
enctype="multipart/form-data"
class="form"
>
<div class="form-group">
<label class="form-label" for="tag_name">
Tag name <span class="form-required">*</span>
</label>
<input
type="text"
id="tag_name"
name="tag_name"
class="form-input"
required
value={values?.tag_name ?? ""}
placeholder="v1.0.0"
/>
</div>
<div class="form-group">
<label class="form-label" for="name">
Release title
Release title{" "}
<span class="form-required">*</span>
</label>
<input
type="text"
id="name"
name="name"
class="form-input"
required
value={values?.name ?? ""}
placeholder="Optional display name"
placeholder="e.g. Version 1.0 — Initial Release"
/>
</div>
<div class="form-group">
<label class="form-label" for="commit_hash">
Commit hash <span class="form-required">*</span>
<label class="checkbox-label">
<input
type="checkbox"
id="create_tag"
name="create_tag"
value="on"
checked={showTagFields}
/>
<span>Create a git tag for this release</span>
</label>
<input
type="text"
id="commit_hash"
name="commit_hash"
class="form-input monospace"
required
value={values?.commit_hash ?? ""}
placeholder="Full or abbreviated commit SHA"
/>
</div>
<div
id="tag-fields-group"
style={showTagFields ? "" : "display:none"}
>
<div class="form-group">
<label class="form-label" for="tag_name">
Tag name{" "}
<span class="form-required">*</span>
</label>
<input
type="text"
id="tag_name"
name="tag_name"
class="form-input"
value={values?.tag_name ?? ""}
placeholder="v1.0.0"
/>
</div>
<div class="form-group">
<label class="form-label" for="revision">
Revision{" "}
<span class="form-required">*</span>
</label>
<input
type="text"
id="revision"
name="revision"
class="form-input monospace"
value={defaultRevision}
placeholder="branch, tag, or commit"
/>
<p class="form-hint">
Branch, tag, or commit to tag.
</p>
</div>
</div>
<div class="form-group">
<label class="form-label" for="notes">
@@ -131,6 +157,15 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
</form>
</div>
</div>
<script>{`
(function() {
var cb = document.getElementById('create_tag');
var group = document.getElementById('tag-fields-group');
cb.addEventListener('change', function() {
group.style.display = cb.checked ? '' : 'none';
});
})();
`}</script>
</Layout>
);
}
▾Msrc/views/releases/ReleaseDetail.tsx
@@ -44,10 +44,7 @@ export function ReleaseDetail({
sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending;
return (
<Layout
user={user}
title={`${release.name || release.tag_name} — ${repo.name}`}
>
<Layout user={user} title={`${release.name} — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="releases" user={user} />
@@ -59,19 +56,18 @@ export function ReleaseDetail({
class="page-title"
style={`view-transition-name: release-title-${release.id}`}
>
{release.name || release.tag_name}
{release.name}
</h2>
<div class="release-item-meta">
</div>
<div class="release-item-date">
{release.tag_name && (
<a
href={`/${repo.name}/tree/${release.commit_hash}`}
class="monospace"
href={`/${repo.name}/tree/${release.tag_name}`}
class="badge"
>
{release.commit_hash.slice(0, 8)}
{release.tag_name}
</a>
</div>
</div>
<div class="release-item-date">
<span class="badge">{release.tag_name}</span>
)}
<time datetime={release.created_at}>
{formatDate(release.created_at)}
</time>
@@ -135,7 +131,7 @@ export function ReleaseDetail({
.map(([format, ext]) => (
<li class="asset-item asset-item-pending">
<span class="asset-name asset-name-pending">
{`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`}
{`${repo.name}-${release.tag_name}${ext}`}
</span>
<span class="asset-meta">
Source code ({format}) —
▾Msrc/views/releases/ReleaseList.tsx
@@ -67,18 +67,9 @@ export function ReleaseList({
class="release-item-title"
style={`view-transition-name: release-title-${release.id}`}
>
{release.name || release.tag_name}
{release.name}
</a>
<div class="release-item-meta">
<a
href={`/${repo.name}/tree/${release.commit_hash}`}
class="monospace"
>
{release.commit_hash.slice(
0,
8,
)}
</a>
{release.asset_count > 0 && (
<span>
{release.asset_count} asset
@@ -94,9 +85,14 @@ export function ReleaseList({
</div>
</div>
<div class="release-item-date">
<span class="badge">
{release.tag_name}
</span>
{release.tag_name && (
<a
href={`/${repo.name}/tree/${release.tag_name}`}
class="badge"
>
{release.tag_name}
</a>
)}
<time datetime={release.created_at}>
{formatDate(release.created_at)}
</time>
▾Msrc/views/repos/BranchSelector.tsx
@@ -36,7 +36,7 @@ export function BranchSelector({
>
{isDetached && (
<option value={currentRef} selected>
{shortRef} (commit)
{shortRef} (detached)
</option>
)}
{branches.map((b) => (
▾Mtests/e2e.test.ts
@@ -1380,7 +1380,6 @@ describe('file browser', () => {
describe('releases', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let commitHash: string;
let releaseUrl: string;
let srcReleaseUrl: string;
let releaseWithAssetsUrl: string;
@@ -1399,7 +1398,6 @@ describe('releases', () => {
} finally { await page.close(); }
await seedRepo('releases-repo');
commitHash = await getHeadCommit('releases-repo');
});
afterAll(async () => {
@@ -1439,7 +1437,7 @@ describe('releases', () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v0.1.0', commit_hash: commitHash },
multipart: { name: 'Test', tag_name: 'v0.1.0' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
@@ -1457,19 +1455,18 @@ describe('releases', () => {
// ── Validation ──────────────────────────────────────────────────────────────
test('empty tag name shows error', async () => {
// Omit tag_name entirely — route now uses t.Optional so app validation runs
test('missing release title shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { commit_hash: commitHash },
multipart: {},
});
expect(await resp.text()).toContain('Tag name is required');
expect(await resp.text()).toContain('Release title is required');
});
test('invalid commit hash shows error', async () => {
test('create_tag checked but no tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' },
multipart: { name: 'Test', create_tag: 'on' },
});
expect(await resp.text()).toContain('Invalid commit');
expect(await resp.text()).toContain('Tag name is required');
});
// ── Create ──────────────────────────────────────────────────────────────────
@@ -1477,9 +1474,10 @@ describe('releases', () => {
test('create a basic release', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
create_tag: 'on',
tag_name: 'v1.0.0',
revision: 'main',
name: 'First release',
commit_hash: commitHash,
notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
},
maxRedirects: 0,
@@ -1492,7 +1490,7 @@ describe('releases', () => {
test('duplicate tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v1.0.0', commit_hash: commitHash },
multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
});
expect(await resp.text()).toContain('already exists');
});
@@ -1508,12 +1506,11 @@ describe('releases', () => {
} finally { await page.close(); }
});
test('release list shows commit hash link', async () => {
test('release list shows tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-meta a.monospace').textContent())
.toBe(commitHash.slice(0, 8));
expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
@@ -1527,14 +1524,12 @@ describe('releases', () => {
// ── Detail ──────────────────────────────────────────────────────────────────
test('release detail shows title, tag badge, and commit link', async () => {
test('release detail shows title and tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('h2.page-title').textContent()).toBe('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
expect(await page.locator('.release-item-meta a.monospace').textContent())
.toBe(commitHash.slice(0, 8));
} finally { await page.close(); }
});
@@ -1550,7 +1545,7 @@ describe('releases', () => {
test('create release with source code archives', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' },
multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
@@ -1583,8 +1578,10 @@ describe('releases', () => {
test('create release with attached file', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
name: 'Asset release',
create_tag: 'on',
tag_name: 'v1.2.0',
commit_hash: commitHash,
revision: 'main',
files: {
name: 'release-asset.txt',
mimeType: 'text/plain',
@@ -1654,7 +1651,7 @@ describe('releases', () => {
// regardless of which browser-based tests above succeeded
for (let i = 1; i <= 25; i++) {
await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash },
multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
maxRedirects: 0,
}).catch(() => {});
}