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 {
116116 interface ReleaseTable {
117117 id: Generated<number>;
118118 repo_id: number;
119- tag_name: string;
120- name: string | null;
119+ tag_name: string | null;
120+ name: string;
121121 notes: string | null;
122- commit_hash: string;
123122 include_source_code: number;
124123 created_at: string;
125124 }
Msrc/db/schema.sql
@@ -114,10 +114,9 @@ CREATE TABLE IF NOT EXISTS ssh_keys (
114114 CREATE TABLE IF NOT EXISTS releases (
115115 id INTEGER PRIMARY KEY AUTOINCREMENT,
116116 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,
119119 notes TEXT,
120- commit_hash TEXT NOT NULL,
121120 include_source_code INTEGER NOT NULL DEFAULT 0,
122121 created_at TEXT NOT NULL,
123122 UNIQUE(repo_id, tag_name)
Msrc/routes/releases.tsx
@@ -4,7 +4,7 @@ import { Elysia, t } from "elysia";
44 import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
55 import { db, getRepo } from "../db/index.ts";
66 import { requireAdmin, resolveSession } from "../middleware/session.ts";
7-import { archiveRepo, validateCommit } from "../services/git.ts";
7+import { archiveRepo, git } from "../services/git.ts";
88 import { renderMarkdown } from "../services/markdown.ts";
99 import { NewRelease } from "../views/releases/NewRelease.tsx";
1010 import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx";
@@ -120,91 +120,117 @@ export const releasesRoutes = new Elysia()
120120 const repo = await getRepo(params.repo, true);
121121 if (!repo) return new Response("Not found", { status: 404 });
122122
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+ };
125132
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())) {
141134 return html(
142135 <NewRelease
143136 user={user!}
144137 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}
151140 />,
152141 );
153142 }
154- if (!commitHash) {
143+
144+ if (!name) {
155145 return html(
156146 <NewRelease
157147 user={user!}
158148 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}
165151 />,
166152 );
167153 }
168154
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+ }
183186 }
184187
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!,
204199 );
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+ }
205230 }
206231
207- const includeSource = body.include_source_code === "on";
232+ const includeSource =
233+ body.include_source_code === "on" && createTag && !!tagName;
208234 const now = new Date().toISOString();
209235
210236 // Collect uploaded file data before opening the transaction so we
@@ -243,9 +269,8 @@ export const releasesRoutes = new Elysia()
243269 .values({
244270 repo_id: repo.id,
245271 tag_name: tagName,
246- name: body.name?.trim() || null,
272+ name,
247273 notes: body.notes?.trim() || null,
248- commit_hash: commitHash,
249274 include_source_code: includeSource ? 1 : 0,
250275 created_at: now,
251276 })
@@ -315,7 +340,7 @@ export const releasesRoutes = new Elysia()
315340 try {
316341 await archiveRepo(
317342 repo.name,
318- commitHash,
343+ tagName!,
319344 repo.name,
320345 sourceDir,
321346 controller.signal,
@@ -343,10 +368,11 @@ export const releasesRoutes = new Elysia()
343368 },
344369 {
345370 body: t.Object({
371+ create_tag: t.Optional(t.String()),
346372 tag_name: t.Optional(t.String()),
373+ revision: t.Optional(t.String()),
347374 name: t.Optional(t.String()),
348375 notes: t.Optional(t.String()),
349- commit_hash: t.Optional(t.String()),
350376 include_source_code: t.Optional(t.String()),
351377 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
352378 }),
@@ -389,7 +415,7 @@ export const releasesRoutes = new Elysia()
389415 }[] = [];
390416 let sourceArchivesPending = false;
391417 if (release.include_source_code) {
392- const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`;
418+ const base = `${repo.name}-${release.tag_name}`;
393419 const sourceDir = path.join(
394420 RELEASES_DIR,
395421 String(release.id),
Msrc/services/git.ts
@@ -59,13 +59,13 @@ export async function validateCommit(
5959
6060 export async function archiveRepo(
6161 repoName: string,
62- commitHash: string,
62+ ref: string,
6363 slug: string,
6464 outDir: string,
6565 signal?: AbortSignal,
6666 ): Promise<void> {
6767 const p = repoPath(repoName);
68- const base = `${slug}-${commitHash.slice(0, 8)}`;
68+ const base = `${slug}-${ref}`;
6969
7070 const zip = Bun.spawn(
7171 [
@@ -75,7 +75,7 @@ export async function archiveRepo(
7575 "archive",
7676 "--format=zip",
7777 `--output=${path.join(outDir, `${base}.zip`)}`,
78- commitHash,
78+ ref,
7979 ],
8080 { signal, env: gitEnv },
8181 );
@@ -89,7 +89,7 @@ export async function archiveRepo(
8989 "archive",
9090 "--format=tar.gz",
9191 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
92- commitHash,
92+ ref,
9393 ],
9494 { signal, env: gitEnv },
9595 );
@@ -98,7 +98,7 @@ export async function archiveRepo(
9898
9999 try {
100100 const tar = Bun.spawn(
101- ["git", "-C", p, "archive", "--format=tar", commitHash],
101+ ["git", "-C", p, "archive", "--format=tar", ref],
102102 { signal, env: gitEnv, stdout: "pipe" },
103103 );
104104 const zst = Bun.spawn(
@@ -417,6 +417,39 @@ export const git = {
417417 });
418418 },
419419
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+
420453 async setHead(name: string, branch: string): Promise<void> {
421454 const p = repoPath(name);
422455 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
Msrc/styles/main.css
@@ -706,6 +706,7 @@
706706 font-size: var(--text-xs);
707707 font-weight: 500;
708708 border: 1px solid;
709+ text-decoration: none;
709710 }
710711 .badge-private {
711712 color: var(--color-warning);
Msrc/views/releases/NewRelease.tsx
@@ -9,15 +9,19 @@ interface NewReleaseProps {
99 repo: RepositoryRow;
1010 error?: string;
1111 values?: {
12+ create_tag?: boolean;
1213 tag_name?: string;
14+ revision?: string;
1315 name?: string;
1416 notes?: string;
15- commit_hash?: string;
1617 include_source_code?: boolean;
1718 };
1819 }
1920
2021 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;
2125 return (
2226 <Layout user={user} title={`New Release — ${repo.name}`}>
2327 <div class="container">
@@ -25,53 +29,75 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
2529 <RepoNav repo={repo} active="releases" user={user} />
2630 <div class="form-page">
2731 <h2 class="page-title">New Release</h2>
28- {error && <div class="flash flash-error">{error}</div>}
32+ {error && <div class="form-error">{error}</div>}
2933 <form
3034 method="post"
3135 action={`/${repo.name}/releases`}
3236 enctype="multipart/form-data"
3337 class="form"
3438 >
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>
4939 <div class="form-group">
5040 <label class="form-label" for="name">
51- Release title
41+ Release title{" "}
42+ <span class="form-required">*</span>
5243 </label>
5344 <input
5445 type="text"
5546 id="name"
5647 name="name"
5748 class="form-input"
49+ required
5850 value={values?.name ?? ""}
59- placeholder="Optional display name"
51+ placeholder="e.g. Version 1.0 — Initial Release"
6052 />
6153 </div>
6254 <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>
6564 </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>
75101 </div>
76102 <div class="form-group">
77103 <label class="form-label" for="notes">
@@ -131,6 +157,15 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
131157 </form>
132158 </div>
133159 </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>
134169 </Layout>
135170 );
136171 }
Msrc/views/releases/ReleaseDetail.tsx
@@ -44,10 +44,7 @@ export function ReleaseDetail({
4444 sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending;
4545
4646 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}`}>
5148 <div class="container">
5249 <RepoHeader repo={repo} />
5350 <RepoNav repo={repo} active="releases" user={user} />
@@ -59,19 +56,18 @@ export function ReleaseDetail({
5956 class="page-title"
6057 style={`view-transition-name: release-title-${release.id}`}
6158 >
62- {release.name || release.tag_name}
59+ {release.name}
6360 </h2>
64- <div class="release-item-meta">
61+ </div>
62+ <div class="release-item-date">
63+ {release.tag_name && (
6564 <a
66- href={`/${repo.name}/tree/${release.commit_hash}`}
67- class="monospace"
65+ href={`/${repo.name}/tree/${release.tag_name}`}
66+ class="badge"
6867 >
69- {release.commit_hash.slice(0, 8)}
68+ {release.tag_name}
7069 </a>
71- </div>
72- </div>
73- <div class="release-item-date">
74- <span class="badge">{release.tag_name}</span>
70+ )}
7571 <time datetime={release.created_at}>
7672 {formatDate(release.created_at)}
7773 </time>
@@ -135,7 +131,7 @@ export function ReleaseDetail({
135131 .map(([format, ext]) => (
136132 <li class="asset-item asset-item-pending">
137133 <span class="asset-name asset-name-pending">
138- {`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`}
134+ {`${repo.name}-${release.tag_name}${ext}`}
139135 </span>
140136 <span class="asset-meta">
141137 Source code ({format}) —
Msrc/views/releases/ReleaseList.tsx
@@ -67,18 +67,9 @@ export function ReleaseList({
6767 class="release-item-title"
6868 style={`view-transition-name: release-title-${release.id}`}
6969 >
70- {release.name || release.tag_name}
70+ {release.name}
7171 </a>
7272 <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>
8273 {release.asset_count > 0 && (
8374 <span>
8475 {release.asset_count} asset
@@ -94,9 +85,14 @@ export function ReleaseList({
9485 </div>
9586 </div>
9687 <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+ )}
10096 <time datetime={release.created_at}>
10197 {formatDate(release.created_at)}
10298 </time>
Msrc/views/repos/BranchSelector.tsx
@@ -36,7 +36,7 @@ export function BranchSelector({
3636 >
3737 {isDetached && (
3838 <option value={currentRef} selected>
39- {shortRef} (commit)
39+ {shortRef} (detached)
4040 </option>
4141 )}
4242 {branches.map((b) => (
Mtests/e2e.test.ts
@@ -1380,7 +1380,6 @@ describe('file browser', () => {
13801380 describe('releases', () => {
13811381 let adminCtx: BrowserContext;
13821382 let aliceCtx: BrowserContext;
1383- let commitHash: string;
13841383 let releaseUrl: string;
13851384 let srcReleaseUrl: string;
13861385 let releaseWithAssetsUrl: string;
@@ -1399,7 +1398,6 @@ describe('releases', () => {
13991398 } finally { await page.close(); }
14001399
14011400 await seedRepo('releases-repo');
1402- commitHash = await getHeadCommit('releases-repo');
14031401 });
14041402
14051403 afterAll(async () => {
@@ -1439,7 +1437,7 @@ describe('releases', () => {
14391437 const page = await aliceCtx.newPage();
14401438 try {
14411439 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' },
14431441 maxRedirects: 0,
14441442 });
14451443 expect(resp.status()).toBe(403);
@@ -1457,19 +1455,18 @@ describe('releases', () => {
14571455
14581456 // ── Validation ──────────────────────────────────────────────────────────────
14591457
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 () => {
14621459 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1463- multipart: { commit_hash: commitHash },
1460+ multipart: {},
14641461 });
1465- expect(await resp.text()).toContain('Tag name is required');
1462+ expect(await resp.text()).toContain('Release title is required');
14661463 });
14671464
1468- test('invalid commit hash shows error', async () => {
1465+ test('create_tag checked but no tag name shows error', async () => {
14691466 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' },
14711468 });
1472- expect(await resp.text()).toContain('Invalid commit');
1469+ expect(await resp.text()).toContain('Tag name is required');
14731470 });
14741471
14751472 // ── Create ──────────────────────────────────────────────────────────────────
@@ -1477,9 +1474,10 @@ describe('releases', () => {
14771474 test('create a basic release', async () => {
14781475 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
14791476 multipart: {
1477+ create_tag: 'on',
14801478 tag_name: 'v1.0.0',
1479+ revision: 'main',
14811480 name: 'First release',
1482- commit_hash: commitHash,
14831481 notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
14841482 },
14851483 maxRedirects: 0,
@@ -1492,7 +1490,7 @@ describe('releases', () => {
14921490
14931491 test('duplicate tag name shows error', async () => {
14941492 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' },
14961494 });
14971495 expect(await resp.text()).toContain('already exists');
14981496 });
@@ -1508,12 +1506,11 @@ describe('releases', () => {
15081506 } finally { await page.close(); }
15091507 });
15101508
1511- test('release list shows commit hash link', async () => {
1509+ test('release list shows tag badge', async () => {
15121510 const page = await adminCtx.newPage();
15131511 try {
15141512 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');
15171514 } finally { await page.close(); }
15181515 });
15191516
@@ -1527,14 +1524,12 @@ describe('releases', () => {
15271524
15281525 // ── Detail ──────────────────────────────────────────────────────────────────
15291526
1530- test('release detail shows title, tag badge, and commit link', async () => {
1527+ test('release detail shows title and tag badge', async () => {
15311528 const page = await adminCtx.newPage();
15321529 try {
15331530 await page.goto(releaseUrl);
15341531 expect(await page.locator('h2.page-title').textContent()).toBe('First release');
15351532 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));
15381533 } finally { await page.close(); }
15391534 });
15401535
@@ -1550,7 +1545,7 @@ describe('releases', () => {
15501545
15511546 test('create release with source code archives', async () => {
15521547 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' },
15541549 maxRedirects: 0,
15551550 });
15561551 expect(resp.status()).toBe(302);
@@ -1583,8 +1578,10 @@ describe('releases', () => {
15831578 test('create release with attached file', async () => {
15841579 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
15851580 multipart: {
1581+ name: 'Asset release',
1582+ create_tag: 'on',
15861583 tag_name: 'v1.2.0',
1587- commit_hash: commitHash,
1584+ revision: 'main',
15881585 files: {
15891586 name: 'release-asset.txt',
15901587 mimeType: 'text/plain',
@@ -1654,7 +1651,7 @@ describe('releases', () => {
16541651 // regardless of which browser-based tests above succeeded
16551652 for (let i = 1; i <= 25; i++) {
16561653 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' },
16581655 maxRedirects: 0,
16591656 }).catch(() => {});
16601657 }