feat: releases — binary assets (dmg, pkg…) attached to tags, API + web UI + CLI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | |