SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%

feat: releases — binary assets (dmg, pkg…) attached to tags, API + web UI + CLI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent cac1be4

Showing 10 changed files with +522 and −25

added cli/commands/release.mjs +118 −0
@@ -0,0 +1,118 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/release.mjs
8 + * Purpose : `spbgit release upload|list|rm` — binary assets on tags
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { createReadStream, statSync, existsSync } from 'node:fs';
14 +import { basename } from 'node:path';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +import { api, EXIT } from '../lib/api.mjs';
18 +import { printTable } from '../lib/ui.mjs';
19 +
20 +function humanBytes(bytes) {
21 + const units = ['B', 'KB', 'MB', 'GB'];
22 + let value = Number(bytes) || 0;
23 + let i = 0;
24 + while (value >= 1024 && i < units.length - 1) {
25 + value /= 1024;
26 + i += 1;
27 + }
28 + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
29 +}
30 +
31 +export function registerRelease(program) {
32 + const release = program.command('release').description('manage release assets (dmg, pkg, zip…) attached to tags');
33 +
34 + release
35 + .command('upload <repo> <tag> <files...>')
36 + .description('upload one or more files as release assets on an existing tag')
37 + .action(async (repo, tag, files) => {
38 + const config = requireCliConfig();
39 + let failures = 0;
40 + for (const file of files) {
41 + if (!existsSync(file)) {
42 + console.error(`${pc.red('✗')} ${file}: file not found`);
43 + failures += 1;
44 + continue;
45 + }
46 + const name = basename(file);
47 + const size = statSync(file).size;
48 + process.stdout.write(`${pc.dim('↑')} ${name} (${humanBytes(size)})… `);
49 + let response;
50 + try {
51 + response = await fetch(
52 + `${config.server}/api/v1/repos/${encodeURIComponent(repo)}/releases/${encodeURIComponent(tag)}/assets/${encodeURIComponent(name)}`,
53 + {
54 + method: 'PUT',
55 + headers: {
56 + Authorization: `Bearer ${config.token}`,
57 + 'Content-Type': 'application/octet-stream',
58 + 'Content-Length': String(size),
59 + },
60 + body: createReadStream(file),
61 + duplex: 'half',
62 + },
63 + );
64 + } catch (err) {
65 + console.log(pc.red(`network error: ${err.cause?.code ?? err.message}`));
66 + failures += 1;
67 + continue;
68 + }
69 + const body = await response.json().catch(() => null);
70 + if (!response.ok) {
71 + console.log(pc.red(`failed: ${body?.error?.message ?? `HTTP ${response.status}`}`));
72 + failures += 1;
73 + continue;
74 + }
75 + console.log(pc.green('✓'));
76 + console.log(` ${pc.dim('url')} ${body.url}`);
77 + console.log(` ${pc.dim('sha256')} ${body.sha256}`);
78 + }
79 + if (failures > 0) process.exit(EXIT.PARTIAL);
80 + });
81 +
82 + release
83 + .command('list <repo>')
84 + .description('list releases and their assets')
85 + .option('--json', 'machine-readable output')
86 + .action(async (repo, opts) => {
87 + const config = requireCliConfig();
88 + const { releases } = await api(config, 'GET', `/api/v1/repos/${encodeURIComponent(repo)}/releases`, undefined, { auth: false });
89 + if (opts.json) {
90 + console.log(JSON.stringify(releases, null, 2));
91 + return;
92 + }
93 + if (releases.length === 0) {
94 + console.log(pc.dim('No tags yet. Tag a version first: git tag v1.0.0 && git push --tags'));
95 + return;
96 + }
97 + const rows = [[pc.bold('TAG'), pc.bold('ASSET'), pc.bold('SIZE'), pc.bold('UPLOADED')]];
98 + for (const r of releases) {
99 + if (r.assets.length === 0) {
100 + rows.push([pc.bold(r.tag), pc.dim('(source archives only)'), '', '']);
101 + continue;
102 + }
103 + r.assets.forEach((a, i) => {
104 + rows.push([i === 0 ? pc.bold(r.tag) : '', a.name, humanBytes(a.size), a.uploaded?.slice(0, 10) ?? '']);
105 + });
106 + }
107 + printTable(rows);
108 + });
109 +
110 + release
111 + .command('rm <repo> <tag> <asset>')
112 + .description('delete a release asset')
113 + .action(async (repo, tag, asset) => {
114 + const config = requireCliConfig();
115 + await api(config, 'DELETE', `/api/v1/repos/${encodeURIComponent(repo)}/releases/${encodeURIComponent(tag)}/assets/${encodeURIComponent(asset)}`);
116 + console.log(`${pc.green('✓')} ${asset} removed from ${repo}@${tag}`);
117 + });
118 +}
modified cli/spbgit.mjs +2 −1
@@ -27,6 +27,7 @@ import { registerOpen } from './commands/open.mjs';
27 27 import { registerInfo } from './commands/info.mjs';
28 28 import { registerRm } from './commands/rm.mjs';
29 29 import { registerDoctor } from './commands/doctor.mjs';
30 +import { registerRelease } from './commands/release.mjs';
30 31
31 32 const program = new Command();
32 33 program
@@ -37,7 +38,7 @@ program
37 38 for (const register of [
38 39 registerInit, registerToken, registerList, registerCreate, registerClone,
39 40 registerStatus, registerCommit, registerPush, registerPull, registerSync,
40 registerOpen, registerInfo, registerRm, registerDoctor,
41 + registerOpen, registerInfo, registerRm, registerDoctor, registerRelease,
41 42 ]) {
42 43 register(program);
43 44 }
modified deploy/ecosystem.config.cjs +4 −2
@@ -46,8 +46,10 @@ module.exports = {
46 46 args: `start --config ${process.env.SPBGIT_NGROK_CONFIG || 'deploy/ngrok.yml'} spbgit`,
47 47 interpreter: 'none',
48 48 autorestart: true,
49 max_restarts: 50,
50 restart_delay: 5000,
49 + // Exponential backoff (caps at 15 min) so the tunnel self-heals once
50 + // the domain reservation / network issue is fixed — without log spam.
51 + max_restarts: 1000,
52 + exp_backoff_restart_delay: 10000,
51 53 out_file: path.join(LOG_DIR, 'tunnel-out.log'),
52 54 error_file: path.join(LOG_DIR, 'tunnel-err.log'),
53 55 merge_logs: true,
modified src/api/v1.mjs +68 −0
@@ -16,6 +16,9 @@ import { makeRequireAuth } from '../auth/token.mjs';
16 16 import { installHooks } from '../git/hooks.mjs';
17 17 import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';
18 18 import { computeLanguages } from '../stats/languages.mjs';
19 +import { isValidAssetName, isValidTagName } from '../git/releases.mjs';
20 +
21 +const ASSET_BODY_LIMIT = 4 * 1024 * 1024 * 1024 + 1024; // 4 GiB + header slack
19 22
20 23 const TOPIC_RE = /^[a-z0-9][a-z0-9-]{0,34}$/;
21 24
@@ -143,6 +146,7 @@ export function registerApi(app, ctx) {
143 146 const trashPath = ctx.repos.softDelete(name);
144 147 ctx.meta.remove(name);
145 148 ctx.cache.bustRepo(name);
149 + ctx.releases.removeRepo(name);
146 150 request.log.info({ name, trashPath }, 'repository soft-deleted');
147 151 return { deleted: name, recoverable: true, note: 'moved to trash — recoverable for 30 days' };
148 152 });
@@ -168,6 +172,70 @@ export function registerApi(app, ctx) {
168 172 return { revoked: request.params.id };
169 173 });
170 174
175 + // ── Releases: binary assets attached to tags (GitHub-style) ──────
176 + app.get('/api/v1/repos/:name/releases', async (request, reply) => {
177 + const { name } = request.params;
178 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
179 + const tags = await ctx.repos.tags(name);
180 + const assets = ctx.releases.list(name);
181 + const releases = tags.map((tag) => {
182 + const key = tag.name.replaceAll('/', '_');
183 + return {
184 + tag: tag.name,
185 + date: tag.date,
186 + subject: tag.subject,
187 + assets: (assets[key] ?? []).map((a) => ({
188 + ...a,
189 + url: `${ctx.config.publicUrl}/releases/${name}/${key}/${encodeURIComponent(a.name)}`,
190 + })),
191 + };
192 + });
193 + return { releases };
194 + });
195 +
196 + app.put('/api/v1/repos/:name/releases/:tag/assets/:asset', {
197 + bodyLimit: ASSET_BODY_LIMIT,
198 + }, async (request, reply) => {
199 + if (!(await requireAuth(request, reply))) return reply;
200 + const { name, tag, asset } = request.params;
201 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
202 + if (!isValidTagName(tag)) return apiError(reply, 400, 'invalid_tag', 'invalid tag name');
203 + if (!isValidAssetName(asset)) {
204 + return apiError(reply, 400, 'invalid_asset', 'asset names must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$');
205 + }
206 + const tags = await ctx.repos.tags(name);
207 + const tagKey = tag.replaceAll('/', '_');
208 + if (!tags.some((t) => t.name.replaceAll('/', '_') === tagKey)) {
209 + return apiError(reply, 400, 'unknown_tag', `tag does not exist: ${tag} — push the tag first (git push --tags)`);
210 + }
211 + if (!request.body || typeof request.body.pipe !== 'function') {
212 + return apiError(reply, 400, 'no_body', 'send the file as the request body with Content-Type: application/octet-stream');
213 + }
214 + try {
215 + const saved = await ctx.releases.save(name, tagKey, asset, request.body);
216 + request.log.info({ name, tag, asset, size: saved.size }, 'release asset uploaded');
217 + return reply.code(201).send({
218 + ...saved,
219 + tag,
220 + url: `${ctx.config.publicUrl}/releases/${name}/${tagKey}/${encodeURIComponent(asset)}`,
221 + });
222 + } catch (err) {
223 + return apiError(reply, 400, 'upload_failed', err.message);
224 + }
225 + });
226 +
227 + app.delete('/api/v1/repos/:name/releases/:tag/assets/:asset', async (request, reply) => {
228 + if (!(await requireAuth(request, reply))) return reply;
229 + const { name, tag, asset } = request.params;
230 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
231 + if (!isValidTagName(tag) || !isValidAssetName(asset)) {
232 + return apiError(reply, 400, 'invalid_name', 'invalid tag or asset name');
233 + }
234 + const ok = ctx.releases.remove(name, tag.replaceAll('/', '_'), asset);
235 + if (!ok) return apiError(reply, 404, 'not_found', 'asset not found');
236 + return { deleted: asset, tag };
237 + });
238 +
171 239 // Auth ping used by `spbgit doctor`.
172 240 app.get('/api/v1/whoami', async (request, reply) => {
173 241 const record = await requireAuth(request, reply);
added src/git/releases.mjs +182 −0
@@ -0,0 +1,182 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/git/releases.mjs
8 + * Purpose : Release assets — binary artifacts (dmg, pkg…) attached to tags
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import {
14 + createWriteStream, createReadStream, existsSync, readdirSync, statSync,
15 + readFileSync, writeFileSync, rmSync, mkdirSync, renameSync,
16 +} from 'node:fs';
17 +import { join, dirname } from 'node:path';
18 +import { createHash, randomBytes } from 'node:crypto';
19 +import { pipeline } from 'node:stream/promises';
20 +import { Transform } from 'node:stream';
21 +import { safeJoin } from '../lib/util.mjs';
22 +
23 +/** Assets live OUTSIDE the bare repos: <dataDir>/releases/<repo>/<tag>/<file>. */
24 +const ASSET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
25 +const TAG_NAME_RE = /^[\w][\w./-]{0,100}$/;
26 +const MAX_ASSET_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
27 +
28 +/** Download MIME types for common artifacts. */
29 +export const ASSET_MIME = Object.freeze({
30 + '.dmg': 'application/x-apple-diskimage',
31 + '.pkg': 'application/octet-stream',
32 + '.zip': 'application/zip',
33 + '.gz': 'application/gzip',
34 + '.tgz': 'application/gzip',
35 + '.xz': 'application/x-xz',
36 + '.zst': 'application/zstd',
37 + '.exe': 'application/vnd.microsoft.portable-executable',
38 + '.msi': 'application/x-msi',
39 + '.deb': 'application/vnd.debian.binary-package',
40 + '.rpm': 'application/x-rpm',
41 + '.appimage': 'application/octet-stream',
42 + '.apk': 'application/vnd.android.package-archive',
43 + '.ipa': 'application/octet-stream',
44 + '.whl': 'application/octet-stream',
45 + '.jar': 'application/java-archive',
46 +});
47 +
48 +/** @param {string} name @returns {boolean} */
49 +export function isValidAssetName(name) {
50 + return typeof name === 'string' && ASSET_NAME_RE.test(name) && !name.endsWith('.sha256');
51 +}
52 +
53 +/** @param {string} tag @returns {boolean} */
54 +export function isValidTagName(tag) {
55 + return typeof tag === 'string' && TAG_NAME_RE.test(tag) && !tag.includes('..');
56 +}
57 +
58 +/**
59 + * Filesystem store for release assets.
60 + */
61 +export class ReleaseStore {
62 + /** @param {string} dataDir */
63 + constructor(dataDir) {
64 + this.root = join(dataDir, 'releases');
65 + }
66 +
67 + /** @returns {string} absolute asset path (traversal-safe) */
68 + assetPath(repo, tag, name) {
69 + if (!isValidTagName(tag) || !isValidAssetName(name)) throw new Error('invalid tag or asset name');
70 + return safeJoin(this.root, repo, tag.replaceAll('/', '_'), name);
71 + }
72 +
73 + /**
74 + * Persist an incoming stream as an asset. Computes sha256 while writing.
75 + * @param {string} repo
76 + * @param {string} tag
77 + * @param {string} name
78 + * @param {NodeJS.ReadableStream} stream
79 + * @returns {Promise<{name: string, size: number, sha256: string}>}
80 + */
81 + async save(repo, tag, name, stream) {
82 + const dest = this.assetPath(repo, tag, name);
83 + mkdirSync(dirname(dest), { recursive: true });
84 + const tmp = `${dest}.${randomBytes(4).toString('hex')}.tmp`;
85 + const hash = createHash('sha256');
86 + let size = 0;
87 + const counter = new Transform({
88 + transform(chunk, _enc, done) {
89 + size += chunk.length;
90 + if (size > MAX_ASSET_BYTES) {
91 + done(new Error('asset exceeds the 4 GiB limit'));
92 + return;
93 + }
94 + hash.update(chunk);
95 + done(null, chunk);
96 + },
97 + });
98 + try {
99 + await pipeline(stream, counter, createWriteStream(tmp));
100 + } catch (err) {
101 + rmSync(tmp, { force: true });
102 + throw err;
103 + }
104 + renameSync(tmp, dest);
105 + const sha256 = hash.digest('hex');
106 + writeFileSync(`${dest}.sha256`, `${sha256} ${name}\n`);
107 + return { name, size, sha256 };
108 + }
109 +
110 + /**
111 + * @returns {{name: string, size: number, sha256: string|null, uploaded: string}|null}
112 + */
113 + stat(repo, tag, name) {
114 + let path;
115 + try {
116 + path = this.assetPath(repo, tag, name);
117 + } catch {
118 + return null;
119 + }
120 + if (!existsSync(path)) return null;
121 + const st = statSync(path);
122 + let sha256 = null;
123 + try {
124 + sha256 = readFileSync(`${path}.sha256`, 'utf8').split(/\s+/)[0] || null;
125 + } catch {
126 + /* older asset without sidecar */
127 + }
128 + return { name, size: st.size, sha256, uploaded: st.mtime.toISOString() };
129 + }
130 +
131 + /**
132 + * All assets of a repo grouped by tag.
133 + * @param {string} repo
134 + * @returns {Record<string, Array<{name: string, size: number, sha256: string|null, uploaded: string}>>}
135 + */
136 + list(repo) {
137 + const repoDir = safeJoin(this.root, repo);
138 + const result = {};
139 + if (!existsSync(repoDir)) return result;
140 + for (const tag of readdirSync(repoDir)) {
141 + const tagDir = join(repoDir, tag);
142 + if (!statSync(tagDir).isDirectory()) continue;
143 + const assets = readdirSync(tagDir)
144 + .filter((f) => !f.endsWith('.sha256') && !f.endsWith('.tmp'))
145 + .map((f) => this.stat(repo, tag, f))
146 + .filter(Boolean)
147 + .sort((a, b) => a.name.localeCompare(b.name));
148 + if (assets.length > 0) result[tag] = assets;
149 + }
150 + return result;
151 + }
152 +
153 + /**
154 + * @returns {boolean} true when the asset existed and was removed
155 + */
156 + remove(repo, tag, name) {
157 + let path;
158 + try {
159 + path = this.assetPath(repo, tag, name);
160 + } catch {
161 + return false;
162 + }
163 + if (!existsSync(path)) return false;
164 + rmSync(path, { force: true });
165 + rmSync(`${path}.sha256`, { force: true });
166 + return true;
167 + }
168 +
169 + /** Remove every asset of a repo (used by repo soft-delete). */
170 + removeRepo(repo) {
171 + try {
172 + rmSync(safeJoin(this.root, repo), { recursive: true, force: true });
173 + } catch {
174 + /* nothing to remove */
175 + }
176 + }
177 +
178 + /** @returns {import('node:fs').ReadStream} */
179 + readStream(repo, tag, name) {
180 + return createReadStream(this.assetPath(repo, tag, name));
181 + }
182 +}
modified src/server.mjs +4 −0
@@ -21,6 +21,7 @@ import { Repos } from './git/repo.mjs';
21 21 import { MetaStore, ActivityFeed } from './lib/store.mjs';
22 22 import { Cache } from './lib/cache.mjs';
23 23 import { TokenStore } from './auth/token.mjs';
24 +import { ReleaseStore } from './git/releases.mjs';
24 25 import { registerSmartHttp } from './git/smart-http.mjs';
25 26 import { registerHookRoutes, ensureHooksInstalled } from './git/hooks.mjs';
26 27 import { registerApi } from './api/v1.mjs';
@@ -57,6 +58,7 @@ export function buildContext(config) {
57 58 tokens: new TokenStore(config.dataDir),
58 59 cache: new Cache(config.cacheDir),
59 60 activity: new ActivityFeed(config.dataDir),
61 + releases: new ReleaseStore(config.dataDir),
60 62 startedAt: Date.now(),
61 63 };
62 64
@@ -147,6 +149,8 @@ export async function buildServer(config) {
147 149 });
148 150
149 151 app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));
152 + // Release-asset uploads stream straight to disk — never buffered in memory.
153 + app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));
150 154
151 155 await app.register(rateLimit, {
152 156 max: 200,
modified src/web/assets/css/app.css +17 −0
@@ -419,3 +419,20 @@ html[data-theme='light'] .icon-sun { display: none; }
419 419 .file-date { display: none; }
420 420 .readme-section .markdown-body, .blob-markdown { padding: 16px; }
421 421 }
422 +
423 +/* ── Releases ────────────────────────────── */
424 +.release-list { display: flex; flex-direction: column; gap: 16px; }
425 +.release-card {
426 + background: var(--surface); border: 1px solid var(--border);
427 + border-radius: var(--radius); padding: 16px 20px;
428 +}
429 +.release-header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
430 +.release-tag { margin: 0; font-size: 17px; font-family: var(--font-mono); }
431 +.release-subject { color: var(--muted); }
432 +.release-assets { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
433 +.release-assets li {
434 + display: flex; align-items: center; gap: 10px;
435 + background: var(--surface-2); border: 1px solid var(--border);
436 + border-radius: var(--radius); padding: 8px 14px; font-size: 14px;
437 +}
438 +.release-assets li svg { color: var(--accent-2); flex: none; }
modified src/web/routes.mjs +31 −3
@@ -21,6 +21,7 @@ import { renderMarkdown } from '../render/markdown.mjs';
21 21 import { highlight, highlightFile, langForPath } from '../render/highlight.mjs';
22 22 import { renderMonogramPng } from '../render/og-image.mjs';
23 23 import { sendArchive } from '../git/archive.mjs';
24 +import { ASSET_MIME, isValidAssetName, isValidTagName } from '../git/releases.mjs';
24 25 import {
25 26 relativeTime, formatBytes, escapeHtml, identiconSvg, isValidRepoName,
26 27 } from '../lib/util.mjs';
@@ -365,6 +366,25 @@ export async function registerWeb(app, ctx) {
365 366 return sendArchive(ctx, name, resolved.sha, format, reply, `${name}-${safeRef}`);
366 367 });
367 368
369 + // ───────────────────────── release asset downloads ─────────────────────────
370 + app.get('/releases/:repo/:tag/:asset', async (request, reply) => {
371 + const name = repoParam(request, reply);
372 + if (!name) return reply;
373 + const { tag, asset } = request.params;
374 + if (!isValidTagName(tag) || !isValidAssetName(asset)) return notFound(request, reply);
375 + const info = ctx.releases.stat(name, tag, asset);
376 + if (!info) return notFound(request, reply);
377 + const ext = posix.extname(asset).toLowerCase();
378 + reply
379 + .type(ASSET_MIME[ext] ?? 'application/octet-stream')
380 + .header('Content-Length', info.size)
381 + .header('Content-Disposition', `attachment; filename="${asset}"`)
382 + .header('X-Content-Type-Options', 'nosniff')
383 + .header('Cache-Control', 'public, max-age=3600');
384 + if (info.sha256) reply.header('X-Checksum-Sha256', info.sha256);
385 + return reply.send(ctx.releases.readStream(name, tag, asset));
386 + });
387 +
368 388 // ───────────────────────── repo home ─────────────────────────
369 389 app.get('/:repo', async (request, reply) => {
370 390 const name = repoParam(request, reply);
@@ -609,18 +629,26 @@ export async function registerWeb(app, ctx) {
609 629 });
610 630 });
611 631
612 app.get('/:repo/tags', async (request, reply) => {
632 + const tagsHandler = async (request, reply) => {
613 633 const name = repoParam(request, reply);
614 634 if (!name) return reply;
615 635 const shell = await repoShell(ctx, name);
636 + const assetsByTag = ctx.releases.list(name);
637 + const tagsWithAssets = shell.tags.map((tag) => {
638 + const key = tag.name.replaceAll('/', '_');
639 + return { ...tag, key, assets: assetsByTag[key] ?? [] };
640 + });
616 641 return render(reply, 'tags.njk', {
617 title: `Tags · ${name} · SPB Git`,
642 + title: `Releases · ${name} · SPB Git`,
618 643 description: `Tags and releases of ${name}`,
619 644 ...shell,
620 645 tab: 'tags',
621 646 currentRef: shell.overview.defaultBranch,
647 + tagsWithAssets,
622 648 canonicalPath: `/${name}/tags`,
623 649 ogImagePath: `/og/${name}.png`,
624 650 });
625 });
651 + };
652 + app.get('/:repo/tags', tagsHandler);
653 + app.get('/:repo/releases', tagsHandler);
626 654 }
modified src/web/views/tags.njk +38 −19
@@ -5,29 +5,48 @@
5 5 Author : Simon-Pierre Boucher
6 6 Contact : contact@spboucher.ai
7 7 File : src/web/views/tags.njk
8 Purpose : Tags list — releases-lite with archive downloads
8 + Purpose : Releases — tags with source archives + binary assets
9 9 License : MIT © Simon-Pierre Boucher
10 10 ─────────────────────────────────────────────
11 11 #}{% extends "layout.njk" %}
12 12 {% block content %}
13 13 {% include "partials/repo-header.njk" %}
14 14
15 <table class="ref-table">
16 <thead><tr><th>Tag</th><th>Message</th><th>Created</th><th>Download</th></tr></thead>
17 <tbody>
18 {% for tag in tags %}
19 <tr>
20 <td><a class="ref-name" href="/{{ overview.name }}/tree/{{ tag.name }}">{{ tag.name }}</a></td>
21 <td class="muted">{{ tag.subject | truncate(60) }}</td>
22 <td class="muted">{{ tag.date | reltime }}</td>
23 <td class="archive-links">
24 <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.zip" rel="nofollow">zip</a>
25 <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.tar.gz" rel="nofollow">tar.gz</a>
26 </td>
27 </tr>
28 {% else %}
29 <tr><td colspan="4" class="muted">No tags yet. Tag a release with <code>git tag v1.0.0 && git push --tags</code>.</td></tr>
30 {% endfor %}
31 </tbody>
32 </table>
15 +{% if tagsWithAssets.length %}
16 +<div class="release-list">
17 + {% for tag in tagsWithAssets %}
18 + <article class="release-card">
19 + <header class="release-header">
20 + <h2 class="release-tag"><a href="/{{ overview.name }}/tree/{{ tag.name }}">{{ tag.name }}</a></h2>
21 + {% if tag.subject %}<span class="release-subject">{{ tag.subject }}</span>{% endif %}
22 + <span class="muted">· {{ tag.date | reltime }}</span>
23 + </header>
24 + {% if tag.assets.length %}
25 + <ul class="release-assets">
26 + {% for asset in tag.assets %}
27 + <li>
28 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1.75V6h2.44a.75.75 0 0 1 .53 1.28l-3.19 3.19a.75.75 0 0 1-1.06 0L4.28 7.28A.75.75 0 0 1 4.81 6h2.44V1.75a.75.75 0 0 1 1.5 0ZM2.5 12.25a.75.75 0 0 0 0 1.5h11a.75.75 0 0 0 0-1.5Z"/></svg>
29 + <a href="/releases/{{ overview.name }}/{{ tag.key }}/{{ asset.name | urlencode }}" rel="nofollow">{{ asset.name }}</a>
30 + <span class="muted">{{ asset.size | bytes }}</span>
31 + {% if asset.sha256 %}
32 + <button class="sha-chip copy-btn" type="button" data-copy="{{ asset.sha256 }}" title="Copy sha256">sha256</button>
33 + {% endif %}
34 + </li>
35 + {% endfor %}
36 + </ul>
37 + {% endif %}
38 + <div class="archive-links">
39 + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.zip" rel="nofollow">Source (zip)</a>
40 + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.tar.gz" rel="nofollow">Source (tar.gz)</a>
41 + </div>
42 + </article>
43 + {% endfor %}
44 +</div>
45 +{% else %}
46 +<div class="empty-state">
47 + <p>No releases yet. Tag a version, push it, then attach artifacts:</p>
48 + <pre class="clone-snippet">git tag v1.0.0 && git push --tags
49 +spbgit release upload {{ overview.name }} v1.0.0 MyApp.dmg</pre>
50 +</div>
51 +{% endif %}
33 52 {% endblock %}
modified test/e2e/roundtrip.test.mjs +58 −0
@@ -201,6 +201,64 @@ describe('full round trip', () => {
201 201 expect((await fetch(`${base}/..%2f..%2fetc.git/info/refs?service=git-upload-pack`)).status).toBe(404);
202 202 });
203 203
204 + it('releases: upload a dmg asset on a tag, download it, checksum matches', async () => {
205 + const work = join(root, 'work');
206 + await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: work, env: GIT_ENV });
207 + const url = new URL(`${base}/e2e-demo.git`);
208 + url.username = 'spb';
209 + url.password = token;
210 + await execFileAsync('git', ['push', '-q', url.href, '--tags'], { cwd: work, env: GIT_ENV });
211 +
212 + const payload = Buffer.from('fake-dmg-content-'.repeat(1000));
213 + const upload = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, {
214 + method: 'PUT',
215 + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },
216 + body: payload,
217 + });
218 + expect(upload.status).toBe(201);
219 + const meta = await upload.json();
220 + expect(meta.size).toBe(payload.length);
221 + expect(meta.sha256).toMatch(/^[0-9a-f]{64}$/);
222 +
223 + // Unauthenticated upload is rejected.
224 + const anon = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/evil.dmg`, {
225 + method: 'PUT',
226 + headers: { 'Content-Type': 'application/octet-stream' },
227 + body: 'x',
228 + });
229 + expect(anon.status).toBe(401);
230 +
231 + // Upload on a missing tag is rejected.
232 + const badTag = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v9.9.9/assets/x.dmg`, {
233 + method: 'PUT',
234 + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },
235 + body: 'x',
236 + });
237 + expect(badTag.status).toBe(400);
238 +
239 + // Public download streams the exact bytes.
240 + const download = await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`);
241 + expect(download.status).toBe(200);
242 + expect(download.headers.get('content-type')).toBe('application/x-apple-diskimage');
243 + expect(download.headers.get('x-checksum-sha256')).toBe(meta.sha256);
244 + expect(Buffer.from(await download.arrayBuffer()).equals(payload)).toBe(true);
245 +
246 + // Listed via API and shown on the releases page.
247 + const { releases } = await (await fetch(`${base}/api/v1/repos/e2e-demo/releases`)).json();
248 + expect(releases[0].tag).toBe('v1.0.0');
249 + expect(releases[0].assets[0].name).toBe('MyApp.dmg');
250 + const page = await (await fetch(`${base}/e2e-demo/releases`)).text();
251 + expect(page).toContain('MyApp.dmg');
252 +
253 + // Delete works and is auth-gated.
254 + const del = await fetch(`${base}/api/v1/repos/e2e-demo/releases/v1.0.0/assets/MyApp.dmg`, {
255 + method: 'DELETE',
256 + headers: { Authorization: `Bearer ${token}` },
257 + });
258 + expect(del.status).toBe(200);
259 + expect((await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`)).status).toBe(404);
260 + }, 60000);
261 +
204 262 it('PATCH updates metadata and DELETE soft-deletes', async () => {
205 263 const patch = await fetch(`${base}/api/v1/repos/e2e-demo`, {
206 264 method: 'PATCH',
207 265