allow creating annotated tags when creating releases
Msrc/db/index.ts
| @@ -116,10 +116,9 @@ interface SshKeyTable { | |||
|---|---|---|---|
| 116 | 116 | interface ReleaseTable { | |
| 117 | 117 | id: Generated<number>; | |
| 118 | 118 | repo_id: number; | |
| 119 | - | tag_name: string; | |
| 120 | - | name: string | null; | |
| 119 | + | tag_name: string | null; | |
| 120 | + | name: string; | |
| 121 | 121 | notes: string | null; | |
| 122 | - | commit_hash: string; | |
| 123 | 122 | include_source_code: number; | |
| 124 | 123 | created_at: string; | |
| 125 | 124 | } | |
Msrc/db/schema.sql
| @@ -114,10 +114,9 @@ CREATE TABLE IF NOT EXISTS ssh_keys ( | |||
|---|---|---|---|
| 114 | 114 | CREATE TABLE IF NOT EXISTS releases ( | |
| 115 | 115 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 116 | 116 | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 117 | - | tag_name TEXT NOT NULL, | |
| 118 | - | name TEXT, | |
| 117 | + | tag_name TEXT, | |
| 118 | + | name TEXT NOT NULL, | |
| 119 | 119 | notes TEXT, | |
| 120 | - | commit_hash TEXT NOT NULL, | |
| 121 | 120 | include_source_code INTEGER NOT NULL DEFAULT 0, | |
| 122 | 121 | created_at TEXT NOT NULL, | |
| 123 | 122 | UNIQUE(repo_id, tag_name) | |
Msrc/routes/releases.tsx
| @@ -4,7 +4,7 @@ import { Elysia, t } from "elysia"; | |||
|---|---|---|---|
| 4 | 4 | import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 5 | 5 | import { db, getRepo } from "../db/index.ts"; | |
| 6 | 6 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 7 | - | import { archiveRepo, validateCommit } from "../services/git.ts"; | |
| 7 | + | import { archiveRepo, git } from "../services/git.ts"; | |
| 8 | 8 | import { renderMarkdown } from "../services/markdown.ts"; | |
| 9 | 9 | import { NewRelease } from "../views/releases/NewRelease.tsx"; | |
| 10 | 10 | import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx"; | |
| @@ -120,91 +120,117 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 120 | 120 | const repo = await getRepo(params.repo, true); | |
| 121 | 121 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 122 | 122 | ||
| 123 | - | const tagName = body.tag_name?.trim() ?? ""; | |
| 124 | - | const commitHash = body.commit_hash?.trim() ?? ""; | |
| 123 | + | const name = body.name?.trim() ?? ""; | |
| 124 | + | const createTag = body.create_tag === "on"; | |
| 125 | + | const tagName = createTag ? (body.tag_name?.trim() ?? "") : null; | |
| 126 | + | const revision = createTag ? (body.revision?.trim() ?? "") : null; | |
| 127 | + | const formValues = { | |
| 128 | + | ...body, | |
| 129 | + | create_tag: createTag, | |
| 130 | + | include_source_code: body.include_source_code === "on", | |
| 131 | + | }; | |
| 125 | 132 | ||
| 126 | - | if (!tagName) { | |
| 127 | - | return html( | |
| 128 | - | <NewRelease | |
| 129 | - | user={user!} | |
| 130 | - | repo={repo} | |
| 131 | - | error="Tag name is required" | |
| 132 | - | values={{ | |
| 133 | - | ...body, | |
| 134 | - | include_source_code: | |
| 135 | - | body.include_source_code === "on", | |
| 136 | - | }} | |
| 137 | - | />, | |
| 138 | - | ); | |
| 139 | - | } | |
| 140 | - | if (tagName.includes("/")) { | |
| 133 | + | if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) { | |
| 141 | 134 | return html( | |
| 142 | 135 | <NewRelease | |
| 143 | 136 | user={user!} | |
| 144 | 137 | repo={repo} | |
| 145 | - | error="Tag name must not contain slashes" | |
| 146 | - | values={{ | |
| 147 | - | ...body, | |
| 148 | - | include_source_code: | |
| 149 | - | body.include_source_code === "on", | |
| 150 | - | }} | |
| 138 | + | error="You must set your git name and email in Settings before creating a tagged release" | |
| 139 | + | values={formValues} | |
| 151 | 140 | />, | |
| 152 | 141 | ); | |
| 153 | 142 | } | |
| 154 | - | if (!commitHash) { | |
| 143 | + | ||
| 144 | + | if (!name) { | |
| 155 | 145 | return html( | |
| 156 | 146 | <NewRelease | |
| 157 | 147 | user={user!} | |
| 158 | 148 | repo={repo} | |
| 159 | - | error="Commit hash is required" | |
| 160 | - | values={{ | |
| 161 | - | ...body, | |
| 162 | - | include_source_code: | |
| 163 | - | body.include_source_code === "on", | |
| 164 | - | }} | |
| 149 | + | error="Release title is required" | |
| 150 | + | values={formValues} | |
| 165 | 151 | />, | |
| 166 | 152 | ); | |
| 167 | 153 | } | |
| 168 | 154 | ||
| 169 | - | const validCommit = await validateCommit(repo.name, commitHash); | |
| 170 | - | if (!validCommit) { | |
| 171 | - | return html( | |
| 172 | - | <NewRelease | |
| 173 | - | user={user!} | |
| 174 | - | repo={repo} | |
| 175 | - | error="Invalid commit hash — no matching commit found in this repository" | |
| 176 | - | values={{ | |
| 177 | - | ...body, | |
| 178 | - | include_source_code: | |
| 179 | - | body.include_source_code === "on", | |
| 180 | - | }} | |
| 181 | - | />, | |
| 182 | - | ); | |
| 155 | + | if (createTag) { | |
| 156 | + | if (!tagName) { | |
| 157 | + | return html( | |
| 158 | + | <NewRelease | |
| 159 | + | user={user!} | |
| 160 | + | repo={repo} | |
| 161 | + | error="Tag name is required when creating a git tag" | |
| 162 | + | values={formValues} | |
| 163 | + | />, | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | if (tagName.includes("/")) { | |
| 167 | + | return html( | |
| 168 | + | <NewRelease | |
| 169 | + | user={user!} | |
| 170 | + | repo={repo} | |
| 171 | + | error="Tag name must not contain slashes" | |
| 172 | + | values={formValues} | |
| 173 | + | />, | |
| 174 | + | ); | |
| 175 | + | } | |
| 176 | + | if (!revision) { | |
| 177 | + | return html( | |
| 178 | + | <NewRelease | |
| 179 | + | user={user!} | |
| 180 | + | repo={repo} | |
| 181 | + | error="Revision is required when creating a git tag" | |
| 182 | + | values={formValues} | |
| 183 | + | />, | |
| 184 | + | ); | |
| 185 | + | } | |
| 183 | 186 | } | |
| 184 | 187 | ||
| 185 | - | // Check for duplicate tag | |
| 186 | - | const existing = await db | |
| 187 | - | .selectFrom("releases") | |
| 188 | - | .select("id") | |
| 189 | - | .where("repo_id", "=", repo.id) | |
| 190 | - | .where("tag_name", "=", tagName) | |
| 191 | - | .executeTakeFirst(); | |
| 192 | - | if (existing) { | |
| 193 | - | return html( | |
| 194 | - | <NewRelease | |
| 195 | - | user={user!} | |
| 196 | - | repo={repo} | |
| 197 | - | error={`A release with tag "${tagName}" already exists`} | |
| 198 | - | values={{ | |
| 199 | - | ...body, | |
| 200 | - | include_source_code: | |
| 201 | - | body.include_source_code === "on", | |
| 202 | - | }} | |
| 203 | - | />, | |
| 188 | + | // Create git tag | |
| 189 | + | if (createTag && tagName && revision) { | |
| 190 | + | const notes = body.notes?.trim() || null; | |
| 191 | + | const tagMessage = notes ? `${name}\n\n${notes}` : name; | |
| 192 | + | const tagResult = await git.createTag( | |
| 193 | + | repo.name, | |
| 194 | + | tagName, | |
| 195 | + | revision, | |
| 196 | + | tagMessage, | |
| 197 | + | user!.git_name!, | |
| 198 | + | user!.git_email!, | |
| 204 | 199 | ); | |
| 200 | + | if (tagResult === "already_exists") { | |
| 201 | + | return html( | |
| 202 | + | <NewRelease | |
| 203 | + | user={user!} | |
| 204 | + | repo={repo} | |
| 205 | + | error={`Git tag "${tagName}" already exists in this repository`} | |
| 206 | + | values={formValues} | |
| 207 | + | />, | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | if (tagResult === "bad_ref") { | |
| 211 | + | return html( | |
| 212 | + | <NewRelease | |
| 213 | + | user={user!} | |
| 214 | + | repo={repo} | |
| 215 | + | error={`"${revision}" is not a valid revision in this repository`} | |
| 216 | + | values={formValues} | |
| 217 | + | />, | |
| 218 | + | ); | |
| 219 | + | } | |
| 220 | + | if (tagResult === "error") { | |
| 221 | + | return html( | |
| 222 | + | <NewRelease | |
| 223 | + | user={user!} | |
| 224 | + | repo={repo} | |
| 225 | + | error="Failed to create git tag" | |
| 226 | + | values={formValues} | |
| 227 | + | />, | |
| 228 | + | ); | |
| 229 | + | } | |
| 205 | 230 | } | |
| 206 | 231 | ||
| 207 | - | const includeSource = body.include_source_code === "on"; | |
| 232 | + | const includeSource = | |
| 233 | + | body.include_source_code === "on" && createTag && !!tagName; | |
| 208 | 234 | const now = new Date().toISOString(); | |
| 209 | 235 | ||
| 210 | 236 | // Collect uploaded file data before opening the transaction so we | |
| @@ -243,9 +269,8 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 243 | 269 | .values({ | |
| 244 | 270 | repo_id: repo.id, | |
| 245 | 271 | tag_name: tagName, | |
| 246 | - | name: body.name?.trim() || null, | |
| 272 | + | name, | |
| 247 | 273 | notes: body.notes?.trim() || null, | |
| 248 | - | commit_hash: commitHash, | |
| 249 | 274 | include_source_code: includeSource ? 1 : 0, | |
| 250 | 275 | created_at: now, | |
| 251 | 276 | }) | |
| @@ -315,7 +340,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 315 | 340 | try { | |
| 316 | 341 | await archiveRepo( | |
| 317 | 342 | repo.name, | |
| 318 | - | commitHash, | |
| 343 | + | tagName!, | |
| 319 | 344 | repo.name, | |
| 320 | 345 | sourceDir, | |
| 321 | 346 | controller.signal, | |
| @@ -343,10 +368,11 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 343 | 368 | }, | |
| 344 | 369 | { | |
| 345 | 370 | body: t.Object({ | |
| 371 | + | create_tag: t.Optional(t.String()), | |
| 346 | 372 | tag_name: t.Optional(t.String()), | |
| 373 | + | revision: t.Optional(t.String()), | |
| 347 | 374 | name: t.Optional(t.String()), | |
| 348 | 375 | notes: t.Optional(t.String()), | |
| 349 | - | commit_hash: t.Optional(t.String()), | |
| 350 | 376 | include_source_code: t.Optional(t.String()), | |
| 351 | 377 | files: t.Optional(t.Union([t.File(), t.Array(t.File())])), | |
| 352 | 378 | }), | |
| @@ -389,7 +415,7 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 389 | 415 | }[] = []; | |
| 390 | 416 | let sourceArchivesPending = false; | |
| 391 | 417 | if (release.include_source_code) { | |
| 392 | - | const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`; | |
| 418 | + | const base = `${repo.name}-${release.tag_name}`; | |
| 393 | 419 | const sourceDir = path.join( | |
| 394 | 420 | RELEASES_DIR, | |
| 395 | 421 | String(release.id), | |
Msrc/services/git.ts
| @@ -59,13 +59,13 @@ export async function validateCommit( | |||
|---|---|---|---|
| 59 | 59 | ||
| 60 | 60 | export async function archiveRepo( | |
| 61 | 61 | repoName: string, | |
| 62 | - | commitHash: string, | |
| 62 | + | ref: string, | |
| 63 | 63 | slug: string, | |
| 64 | 64 | outDir: string, | |
| 65 | 65 | signal?: AbortSignal, | |
| 66 | 66 | ): Promise<void> { | |
| 67 | 67 | const p = repoPath(repoName); | |
| 68 | - | const base = `${slug}-${commitHash.slice(0, 8)}`; | |
| 68 | + | const base = `${slug}-${ref}`; | |
| 69 | 69 | ||
| 70 | 70 | const zip = Bun.spawn( | |
| 71 | 71 | [ | |
| @@ -75,7 +75,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 75 | 75 | "archive", | |
| 76 | 76 | "--format=zip", | |
| 77 | 77 | `--output=${path.join(outDir, `${base}.zip`)}`, | |
| 78 | - | commitHash, | |
| 78 | + | ref, | |
| 79 | 79 | ], | |
| 80 | 80 | { signal, env: gitEnv }, | |
| 81 | 81 | ); | |
| @@ -89,7 +89,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 89 | 89 | "archive", | |
| 90 | 90 | "--format=tar.gz", | |
| 91 | 91 | `--output=${path.join(outDir, `${base}.tar.gz`)}`, | |
| 92 | - | commitHash, | |
| 92 | + | ref, | |
| 93 | 93 | ], | |
| 94 | 94 | { signal, env: gitEnv }, | |
| 95 | 95 | ); | |
| @@ -98,7 +98,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 98 | 98 | ||
| 99 | 99 | try { | |
| 100 | 100 | const tar = Bun.spawn( | |
| 101 | - | ["git", "-C", p, "archive", "--format=tar", commitHash], | |
| 101 | + | ["git", "-C", p, "archive", "--format=tar", ref], | |
| 102 | 102 | { signal, env: gitEnv, stdout: "pipe" }, | |
| 103 | 103 | ); | |
| 104 | 104 | const zst = Bun.spawn( | |
| @@ -417,6 +417,39 @@ export const git = { | |||
|---|---|---|---|
| 417 | 417 | }); | |
| 418 | 418 | }, | |
| 419 | 419 | ||
| 420 | + | async createTag( | |
| 421 | + | repoName: string, | |
| 422 | + | tagName: string, | |
| 423 | + | ref: string, | |
| 424 | + | message?: string, | |
| 425 | + | taggerName?: string, | |
| 426 | + | taggerEmail?: string, | |
| 427 | + | ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> { | |
| 428 | + | return withRepoLock(repoName, async () => { | |
| 429 | + | const p = repoPath(repoName); | |
| 430 | + | const result = | |
| 431 | + | message !== undefined | |
| 432 | + | ? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}` | |
| 433 | + | .env({ | |
| 434 | + | ...gitEnv, | |
| 435 | + | GIT_COMMITTER_NAME: taggerName!, | |
| 436 | + | GIT_COMMITTER_EMAIL: taggerEmail!, | |
| 437 | + | }) | |
| 438 | + | .nothrow() | |
| 439 | + | : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow(); | |
| 440 | + | if (result.exitCode === 0) return "ok"; | |
| 441 | + | const stderr = result.stderr.toString(); | |
| 442 | + | if (stderr.includes("already exists")) return "already_exists"; | |
| 443 | + | if ( | |
| 444 | + | stderr.includes("not a valid object name") || | |
| 445 | + | stderr.includes("unknown revision") || | |
| 446 | + | stderr.includes("ambiguous argument") | |
| 447 | + | ) | |
| 448 | + | return "bad_ref"; | |
| 449 | + | return "error"; | |
| 450 | + | }); | |
| 451 | + | }, | |
| 452 | + | ||
| 420 | 453 | async setHead(name: string, branch: string): Promise<void> { | |
| 421 | 454 | const p = repoPath(name); | |
| 422 | 455 | await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`; | |
Msrc/styles/main.css
| @@ -706,6 +706,7 @@ | |||
|---|---|---|---|
| 706 | 706 | font-size: var(--text-xs); | |
| 707 | 707 | font-weight: 500; | |
| 708 | 708 | border: 1px solid; | |
| 709 | + | text-decoration: none; | |
| 709 | 710 | } | |
| 710 | 711 | .badge-private { | |
| 711 | 712 | color: var(--color-warning); | |
Msrc/views/releases/NewRelease.tsx
| @@ -9,15 +9,19 @@ interface NewReleaseProps { | |||
|---|---|---|---|
| 9 | 9 | repo: RepositoryRow; | |
| 10 | 10 | error?: string; | |
| 11 | 11 | values?: { | |
| 12 | + | create_tag?: boolean; | |
| 12 | 13 | tag_name?: string; | |
| 14 | + | revision?: string; | |
| 13 | 15 | name?: string; | |
| 14 | 16 | notes?: string; | |
| 15 | - | commit_hash?: string; | |
| 16 | 17 | include_source_code?: boolean; | |
| 17 | 18 | }; | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | export function NewRelease({ user, repo, error, values }: NewReleaseProps) { | |
| 22 | + | const showTagFields = values?.create_tag ?? false; | |
| 23 | + | const defaultRevision = | |
| 24 | + | values?.revision !== undefined ? values.revision : repo.default_branch; | |
| 21 | 25 | return ( | |
| 22 | 26 | <Layout user={user} title={`New Release — ${repo.name}`}> | |
| 23 | 27 | <div class="container"> | |
| @@ -25,53 +29,75 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) { | |||
|---|---|---|---|
| 25 | 29 | <RepoNav repo={repo} active="releases" user={user} /> | |
| 26 | 30 | <div class="form-page"> | |
| 27 | 31 | <h2 class="page-title">New Release</h2> | |
| 28 | - | {error && <div class="flash flash-error">{error}</div>} | |
| 32 | + | {error && <div class="form-error">{error}</div>} | |
| 29 | 33 | <form | |
| 30 | 34 | method="post" | |
| 31 | 35 | action={`/${repo.name}/releases`} | |
| 32 | 36 | enctype="multipart/form-data" | |
| 33 | 37 | class="form" | |
| 34 | 38 | > | |
| 35 | - | <div class="form-group"> | |
| 36 | - | <label class="form-label" for="tag_name"> | |
| 37 | - | Tag name <span class="form-required">*</span> | |
| 38 | - | </label> | |
| 39 | - | <input | |
| 40 | - | type="text" | |
| 41 | - | id="tag_name" | |
| 42 | - | name="tag_name" | |
| 43 | - | class="form-input" | |
| 44 | - | required | |
| 45 | - | value={values?.tag_name ?? ""} | |
| 46 | - | placeholder="v1.0.0" | |
| 47 | - | /> | |
| 48 | - | </div> | |
| 49 | 39 | <div class="form-group"> | |
| 50 | 40 | <label class="form-label" for="name"> | |
| 51 | - | Release title | |
| 41 | + | Release title{" "} | |
| 42 | + | <span class="form-required">*</span> | |
| 52 | 43 | </label> | |
| 53 | 44 | <input | |
| 54 | 45 | type="text" | |
| 55 | 46 | id="name" | |
| 56 | 47 | name="name" | |
| 57 | 48 | class="form-input" | |
| 49 | + | required | |
| 58 | 50 | value={values?.name ?? ""} | |
| 59 | - | placeholder="Optional display name" | |
| 51 | + | placeholder="e.g. Version 1.0 — Initial Release" | |
| 60 | 52 | /> | |
| 61 | 53 | </div> | |
| 62 | 54 | <div class="form-group"> | |
| 63 | - | <label class="form-label" for="commit_hash"> | |
| 64 | - | Commit hash <span class="form-required">*</span> | |
| 55 | + | <label class="checkbox-label"> | |
| 56 | + | <input | |
| 57 | + | type="checkbox" | |
| 58 | + | id="create_tag" | |
| 59 | + | name="create_tag" | |
| 60 | + | value="on" | |
| 61 | + | checked={showTagFields} | |
| 62 | + | /> | |
| 63 | + | <span>Create a git tag for this release</span> | |
| 65 | 64 | </label> | |
| 66 | - | <input | |
| 67 | - | type="text" | |
| 68 | - | id="commit_hash" | |
| 69 | - | name="commit_hash" | |
| 70 | - | class="form-input monospace" | |
| 71 | - | required | |
| 72 | - | value={values?.commit_hash ?? ""} | |
| 73 | - | placeholder="Full or abbreviated commit SHA" | |
| 74 | - | /> | |
| 65 | + | </div> | |
| 66 | + | <div | |
| 67 | + | id="tag-fields-group" | |
| 68 | + | style={showTagFields ? "" : "display:none"} | |
| 69 | + | > | |
| 70 | + | <div class="form-group"> | |
| 71 | + | <label class="form-label" for="tag_name"> | |
| 72 | + | Tag name{" "} | |
| 73 | + | <span class="form-required">*</span> | |
| 74 | + | </label> | |
| 75 | + | <input | |
| 76 | + | type="text" | |
| 77 | + | id="tag_name" | |
| 78 | + | name="tag_name" | |
| 79 | + | class="form-input" | |
| 80 | + | value={values?.tag_name ?? ""} | |
| 81 | + | placeholder="v1.0.0" | |
| 82 | + | /> | |
| 83 | + | </div> | |
| 84 | + | <div class="form-group"> | |
| 85 | + | <label class="form-label" for="revision"> | |
| 86 | + | Revision{" "} | |
| 87 | + | <span class="form-required">*</span> | |
| 88 | + | </label> | |
| 89 | + | <input | |
| 90 | + | type="text" | |
| 91 | + | id="revision" | |
| 92 | + | name="revision" | |
| 93 | + | class="form-input monospace" | |
| 94 | + | value={defaultRevision} | |
| 95 | + | placeholder="branch, tag, or commit" | |
| 96 | + | /> | |
| 97 | + | <p class="form-hint"> | |
| 98 | + | Branch, tag, or commit to tag. | |
| 99 | + | </p> | |
| 100 | + | </div> | |
| 75 | 101 | </div> | |
| 76 | 102 | <div class="form-group"> | |
| 77 | 103 | <label class="form-label" for="notes"> | |
| @@ -131,6 +157,15 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) { | |||
|---|---|---|---|
| 131 | 157 | </form> | |
| 132 | 158 | </div> | |
| 133 | 159 | </div> | |
| 160 | + | <script>{` | |
| 161 | + | (function() { | |
| 162 | + | var cb = document.getElementById('create_tag'); | |
| 163 | + | var group = document.getElementById('tag-fields-group'); | |
| 164 | + | cb.addEventListener('change', function() { | |
| 165 | + | group.style.display = cb.checked ? '' : 'none'; | |
| 166 | + | }); | |
| 167 | + | })(); | |
| 168 | + | `}</script> | |
| 134 | 169 | </Layout> | |
| 135 | 170 | ); | |
| 136 | 171 | } | |
Msrc/views/releases/ReleaseDetail.tsx
| @@ -44,10 +44,7 @@ export function ReleaseDetail({ | |||
|---|---|---|---|
| 44 | 44 | sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending; | |
| 45 | 45 | ||
| 46 | 46 | return ( | |
| 47 | - | <Layout | |
| 48 | - | user={user} | |
| 49 | - | title={`${release.name || release.tag_name} — ${repo.name}`} | |
| 50 | - | > | |
| 47 | + | <Layout user={user} title={`${release.name} — ${repo.name}`}> | |
| 51 | 48 | <div class="container"> | |
| 52 | 49 | <RepoHeader repo={repo} /> | |
| 53 | 50 | <RepoNav repo={repo} active="releases" user={user} /> | |
| @@ -59,19 +56,18 @@ export function ReleaseDetail({ | |||
|---|---|---|---|
| 59 | 56 | class="page-title" | |
| 60 | 57 | style={`view-transition-name: release-title-${release.id}`} | |
| 61 | 58 | > | |
| 62 | - | {release.name || release.tag_name} | |
| 59 | + | {release.name} | |
| 63 | 60 | </h2> | |
| 64 | - | <div class="release-item-meta"> | |
| 61 | + | </div> | |
| 62 | + | <div class="release-item-date"> | |
| 63 | + | {release.tag_name && ( | |
| 65 | 64 | <a | |
| 66 | - | href={`/${repo.name}/tree/${release.commit_hash}`} | |
| 67 | - | class="monospace" | |
| 65 | + | href={`/${repo.name}/tree/${release.tag_name}`} | |
| 66 | + | class="badge" | |
| 68 | 67 | > | |
| 69 | - | {release.commit_hash.slice(0, 8)} | |
| 68 | + | {release.tag_name} | |
| 70 | 69 | </a> | |
| 71 | - | </div> | |
| 72 | - | </div> | |
| 73 | - | <div class="release-item-date"> | |
| 74 | - | <span class="badge">{release.tag_name}</span> | |
| 70 | + | )} | |
| 75 | 71 | <time datetime={release.created_at}> | |
| 76 | 72 | {formatDate(release.created_at)} | |
| 77 | 73 | </time> | |
| @@ -135,7 +131,7 @@ export function ReleaseDetail({ | |||
|---|---|---|---|
| 135 | 131 | .map(([format, ext]) => ( | |
| 136 | 132 | <li class="asset-item asset-item-pending"> | |
| 137 | 133 | <span class="asset-name asset-name-pending"> | |
| 138 | - | {`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`} | |
| 134 | + | {`${repo.name}-${release.tag_name}${ext}`} | |
| 139 | 135 | </span> | |
| 140 | 136 | <span class="asset-meta"> | |
| 141 | 137 | Source code ({format}) — | |
Msrc/views/releases/ReleaseList.tsx
| @@ -67,18 +67,9 @@ export function ReleaseList({ | |||
|---|---|---|---|
| 67 | 67 | class="release-item-title" | |
| 68 | 68 | style={`view-transition-name: release-title-${release.id}`} | |
| 69 | 69 | > | |
| 70 | - | {release.name || release.tag_name} | |
| 70 | + | {release.name} | |
| 71 | 71 | </a> | |
| 72 | 72 | <div class="release-item-meta"> | |
| 73 | - | <a | |
| 74 | - | href={`/${repo.name}/tree/${release.commit_hash}`} | |
| 75 | - | class="monospace" | |
| 76 | - | > | |
| 77 | - | {release.commit_hash.slice( | |
| 78 | - | 0, | |
| 79 | - | 8, | |
| 80 | - | )} | |
| 81 | - | </a> | |
| 82 | 73 | {release.asset_count > 0 && ( | |
| 83 | 74 | <span> | |
| 84 | 75 | {release.asset_count} asset | |
| @@ -94,9 +85,14 @@ export function ReleaseList({ | |||
|---|---|---|---|
| 94 | 85 | </div> | |
| 95 | 86 | </div> | |
| 96 | 87 | <div class="release-item-date"> | |
| 97 | - | <span class="badge"> | |
| 98 | - | {release.tag_name} | |
| 99 | - | </span> | |
| 88 | + | {release.tag_name && ( | |
| 89 | + | <a | |
| 90 | + | href={`/${repo.name}/tree/${release.tag_name}`} | |
| 91 | + | class="badge" | |
| 92 | + | > | |
| 93 | + | {release.tag_name} | |
| 94 | + | </a> | |
| 95 | + | )} | |
| 100 | 96 | <time datetime={release.created_at}> | |
| 101 | 97 | {formatDate(release.created_at)} | |
| 102 | 98 | </time> | |
Msrc/views/repos/BranchSelector.tsx
| @@ -36,7 +36,7 @@ export function BranchSelector({ | |||
|---|---|---|---|
| 36 | 36 | > | |
| 37 | 37 | {isDetached && ( | |
| 38 | 38 | <option value={currentRef} selected> | |
| 39 | - | {shortRef} (commit) | |
| 39 | + | {shortRef} (detached) | |
| 40 | 40 | </option> | |
| 41 | 41 | )} | |
| 42 | 42 | {branches.map((b) => ( | |
Mtests/e2e.test.ts
| @@ -1380,7 +1380,6 @@ describe('file browser', () => { | |||
|---|---|---|---|
| 1380 | 1380 | describe('releases', () => { | |
| 1381 | 1381 | let adminCtx: BrowserContext; | |
| 1382 | 1382 | let aliceCtx: BrowserContext; | |
| 1383 | - | let commitHash: string; | |
| 1384 | 1383 | let releaseUrl: string; | |
| 1385 | 1384 | let srcReleaseUrl: string; | |
| 1386 | 1385 | let releaseWithAssetsUrl: string; | |
| @@ -1399,7 +1398,6 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1399 | 1398 | } finally { await page.close(); } | |
| 1400 | 1399 | ||
| 1401 | 1400 | await seedRepo('releases-repo'); | |
| 1402 | - | commitHash = await getHeadCommit('releases-repo'); | |
| 1403 | 1401 | }); | |
| 1404 | 1402 | ||
| 1405 | 1403 | afterAll(async () => { | |
| @@ -1439,7 +1437,7 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1439 | 1437 | const page = await aliceCtx.newPage(); | |
| 1440 | 1438 | try { | |
| 1441 | 1439 | const resp = await page.request.post(`${BASE}/releases-repo/releases`, { | |
| 1442 | - | multipart: { tag_name: 'v0.1.0', commit_hash: commitHash }, | |
| 1440 | + | multipart: { name: 'Test', tag_name: 'v0.1.0' }, | |
| 1443 | 1441 | maxRedirects: 0, | |
| 1444 | 1442 | }); | |
| 1445 | 1443 | expect(resp.status()).toBe(403); | |
| @@ -1457,19 +1455,18 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1457 | 1455 | ||
| 1458 | 1456 | // ── Validation ────────────────────────────────────────────────────────────── | |
| 1459 | 1457 | ||
| 1460 | - | test('empty tag name shows error', async () => { | |
| 1461 | - | // Omit tag_name entirely — route now uses t.Optional so app validation runs | |
| 1458 | + | test('missing release title shows error', async () => { | |
| 1462 | 1459 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1463 | - | multipart: { commit_hash: commitHash }, | |
| 1460 | + | multipart: {}, | |
| 1464 | 1461 | }); | |
| 1465 | - | expect(await resp.text()).toContain('Tag name is required'); | |
| 1462 | + | expect(await resp.text()).toContain('Release title is required'); | |
| 1466 | 1463 | }); | |
| 1467 | 1464 | ||
| 1468 | - | test('invalid commit hash shows error', async () => { | |
| 1465 | + | test('create_tag checked but no tag name shows error', async () => { | |
| 1469 | 1466 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1470 | - | multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' }, | |
| 1467 | + | multipart: { name: 'Test', create_tag: 'on' }, | |
| 1471 | 1468 | }); | |
| 1472 | - | expect(await resp.text()).toContain('Invalid commit'); | |
| 1469 | + | expect(await resp.text()).toContain('Tag name is required'); | |
| 1473 | 1470 | }); | |
| 1474 | 1471 | ||
| 1475 | 1472 | // ── Create ────────────────────────────────────────────────────────────────── | |
| @@ -1477,9 +1474,10 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1477 | 1474 | test('create a basic release', async () => { | |
| 1478 | 1475 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1479 | 1476 | multipart: { | |
| 1477 | + | create_tag: 'on', | |
| 1480 | 1478 | tag_name: 'v1.0.0', | |
| 1479 | + | revision: 'main', | |
| 1481 | 1480 | name: 'First release', | |
| 1482 | - | commit_hash: commitHash, | |
| 1483 | 1481 | notes: 'Initial stable release.\n\n- Feature A\n- Feature B', | |
| 1484 | 1482 | }, | |
| 1485 | 1483 | maxRedirects: 0, | |
| @@ -1492,7 +1490,7 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1492 | 1490 | ||
| 1493 | 1491 | test('duplicate tag name shows error', async () => { | |
| 1494 | 1492 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1495 | - | multipart: { tag_name: 'v1.0.0', commit_hash: commitHash }, | |
| 1493 | + | multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' }, | |
| 1496 | 1494 | }); | |
| 1497 | 1495 | expect(await resp.text()).toContain('already exists'); | |
| 1498 | 1496 | }); | |
| @@ -1508,12 +1506,11 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1508 | 1506 | } finally { await page.close(); } | |
| 1509 | 1507 | }); | |
| 1510 | 1508 | ||
| 1511 | - | test('release list shows commit hash link', async () => { | |
| 1509 | + | test('release list shows tag badge', async () => { | |
| 1512 | 1510 | const page = await adminCtx.newPage(); | |
| 1513 | 1511 | try { | |
| 1514 | 1512 | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1515 | - | expect(await page.locator('.release-item-meta a.monospace').textContent()) | |
| 1516 | - | .toBe(commitHash.slice(0, 8)); | |
| 1513 | + | expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0'); | |
| 1517 | 1514 | } finally { await page.close(); } | |
| 1518 | 1515 | }); | |
| 1519 | 1516 | ||
| @@ -1527,14 +1524,12 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1527 | 1524 | ||
| 1528 | 1525 | // ── Detail ────────────────────────────────────────────────────────────────── | |
| 1529 | 1526 | ||
| 1530 | - | test('release detail shows title, tag badge, and commit link', async () => { | |
| 1527 | + | test('release detail shows title and tag badge', async () => { | |
| 1531 | 1528 | const page = await adminCtx.newPage(); | |
| 1532 | 1529 | try { | |
| 1533 | 1530 | await page.goto(releaseUrl); | |
| 1534 | 1531 | expect(await page.locator('h2.page-title').textContent()).toBe('First release'); | |
| 1535 | 1532 | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 1536 | - | expect(await page.locator('.release-item-meta a.monospace').textContent()) | |
| 1537 | - | .toBe(commitHash.slice(0, 8)); | |
| 1538 | 1533 | } finally { await page.close(); } | |
| 1539 | 1534 | }); | |
| 1540 | 1535 | ||
| @@ -1550,7 +1545,7 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1550 | 1545 | ||
| 1551 | 1546 | test('create release with source code archives', async () => { | |
| 1552 | 1547 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1553 | - | multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' }, | |
| 1548 | + | multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' }, | |
| 1554 | 1549 | maxRedirects: 0, | |
| 1555 | 1550 | }); | |
| 1556 | 1551 | expect(resp.status()).toBe(302); | |
| @@ -1583,8 +1578,10 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1583 | 1578 | test('create release with attached file', async () => { | |
| 1584 | 1579 | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1585 | 1580 | multipart: { | |
| 1581 | + | name: 'Asset release', | |
| 1582 | + | create_tag: 'on', | |
| 1586 | 1583 | tag_name: 'v1.2.0', | |
| 1587 | - | commit_hash: commitHash, | |
| 1584 | + | revision: 'main', | |
| 1588 | 1585 | files: { | |
| 1589 | 1586 | name: 'release-asset.txt', | |
| 1590 | 1587 | mimeType: 'text/plain', | |
| @@ -1654,7 +1651,7 @@ describe('releases', () => { | |||
|---|---|---|---|
| 1654 | 1651 | // regardless of which browser-based tests above succeeded | |
| 1655 | 1652 | for (let i = 1; i <= 25; i++) { | |
| 1656 | 1653 | await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1657 | - | multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash }, | |
| 1654 | + | multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' }, | |
| 1658 | 1655 | maxRedirects: 0, | |
| 1659 | 1656 | }).catch(() => {}); | |
| 1660 | 1657 | } | |