diff --git a/src/cli.js b/src/cli.js
index 1c5713f..e81cdef 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -3,6 +3,7 @@
const { Command, InvalidArgumentError } = require('commander');
const readline = require('node:readline');
const fs = require('node:fs');
+const path = require('node:path');
const { execSync, spawnSync } = require('node:child_process');
const { stdin: input, stdout: output } = require('node:process');
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
@@ -92,6 +93,33 @@ function parseDepth(value) {
return n;
}
+function collectOption(value, previous) {
+ return previous.concat(value);
+}
+
+function validateAssetOptions(options) {
+ if (options.assetName && options.asset.length !== 1) {
+ throw new Error('--asset-name requires exactly one --asset.');
+ }
+}
+
+async function uploadAssets(client, owner, repo, releaseId, assetPaths, assetName) {
+ const uploaded = [];
+ const failed = [];
+ for (const assetPath of assetPaths) {
+ const name = assetName || path.basename(assetPath);
+ try {
+ await client.uploadReleaseAsset(owner, repo, releaseId, assetPath, name);
+ uploaded.push(name);
+ console.log(`Asset uploaded: ${name}`);
+ } catch (err) {
+ failed.push({ name, error: err });
+ console.error(`Asset failed: ${name}: ${err.message}`);
+ }
+ }
+ return { uploaded, failed };
+}
+
// Read commands share a --json flag that prints the raw API response
// (pretty-printed) instead of the human-readable format.
function printJson(data) {
@@ -1064,10 +1092,13 @@ release
.option('-t, --title
', 'release title (default: the tag name)')
.option('-b, --body ', 'release notes (markdown)')
.option('--body-file ', 'read the release notes from a file (wins over -b)')
+ .option('--asset ', 'attach an asset (repeatable)', collectOption, [])
+ .option('--asset-name ', 'override the uploaded filename (exactly one asset)')
.option('--draft', 'create as a draft release', false)
.option('--prerelease', 'mark as a prerelease', false)
.action(async (options) => {
try {
+ validateAssetOptions(options);
const config = loadConfig();
const client = ForgejoClient.fromConfig(config);
const payload = {
@@ -1080,7 +1111,22 @@ release
if (options.target) payload.target_commitish = options.target;
const result = await client.createRelease(options.owner, options.repo, payload);
console.log(`Release created: ${result.tag_name} ${result.name || ''}`.trimEnd());
+ console.log(`Release id: ${result.id}`);
console.log(`URL: ${result.html_url}`);
+ const uploads = await uploadAssets(
+ client,
+ options.owner,
+ options.repo,
+ result.id,
+ options.asset,
+ options.assetName,
+ );
+ if (uploads.failed.length) {
+ console.error(
+ `${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed. The release was kept.`,
+ );
+ process.exitCode = 1;
+ }
} catch (err) {
console.error(`Release creation failed: ${err.message}`);
if (err.status) console.error(`HTTP status: ${err.status}`);
@@ -1088,6 +1134,39 @@ release
}
});
+release
+ .command('upload')
+ .description('Attach assets to an existing release')
+ .requiredOption('-o, --owner ', 'repository owner')
+ .requiredOption('-r, --repo ', 'repository name')
+ .requiredOption('--tag ', 'tag name of the release')
+ .requiredOption('--asset ', 'asset to upload (repeatable)', collectOption, [])
+ .option('--asset-name ', 'override the uploaded filename (exactly one asset)')
+ .action(async (options) => {
+ try {
+ validateAssetOptions(options);
+ const config = loadConfig();
+ const client = ForgejoClient.fromConfig(config);
+ const releaseResult = await client.getReleaseByTag(options.owner, options.repo, options.tag);
+ const uploads = await uploadAssets(
+ client,
+ options.owner,
+ options.repo,
+ releaseResult.id,
+ options.asset,
+ options.assetName,
+ );
+ if (uploads.failed.length) {
+ console.error(`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed.`);
+ process.exitCode = 1;
+ }
+ } catch (err) {
+ console.error(`Release upload failed: ${err.message}`);
+ if (err.status) console.error(`HTTP status: ${err.status}`);
+ process.exit(1);
+ }
+ });
+
function parseColor(value) {
const hex = value.replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
diff --git a/test/cli.test.js b/test/cli.test.js
index d503850..de84f8b 100644
--- a/test/cli.test.js
+++ b/test/cli.test.js
@@ -62,6 +62,173 @@ test('repo create help lists the owner option', () => {
assert.match(res.stdout, /-o, --owner /);
});
+test('release asset commands expose repeatable assets and a single-asset name override', () => {
+ const create = run(['release', 'create', '--help']);
+ assert.equal(create.status, 0, create.stderr);
+ assert.match(create.stdout, /--asset /);
+ assert.match(create.stdout, /--asset-name /);
+
+ const upload = run(['release', 'upload', '--help']);
+ assert.equal(upload.status, 0, upload.stderr);
+ assert.match(upload.stdout, /--tag /);
+ assert.match(upload.stdout, /--asset /);
+ assert.match(upload.stdout, /--asset-name /);
+});
+
+test('release create rejects one asset name for multiple assets before reading config', () => {
+ const res = run([
+ 'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
+ '--asset', 'one.bin', '--asset', 'two.bin', '--asset-name', 'named.bin',
+ ], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
+ assert.equal(res.status, 1);
+ assert.match(res.stderr, /--asset-name requires exactly one --asset/);
+ assert.doesNotMatch(res.stderr, /Not authenticated/);
+});
+
+test('release create prints the id and uploads every asset as multipart data', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-create-'));
+ const cfg = path.join(dir, 'config.json');
+ const first = path.join(dir, 'first.bin');
+ const second = path.join(dir, 'second.bin');
+ fs.writeFileSync(first, 'first payload');
+ fs.writeFileSync(second, 'second payload');
+ const requests = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (chunk) => chunks.push(chunk));
+ req.on('end', () => {
+ requests.push({
+ method: req.method,
+ url: req.url,
+ contentType: req.headers['content-type'],
+ body: Buffer.concat(chunks).toString('utf8'),
+ });
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/api/v1/repos/o/r/releases') {
+ res.writeHead(201);
+ res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'Version 1', html_url: 'https://forge.test/o/r/releases/v1' }));
+ } else {
+ res.writeHead(201);
+ res.end(JSON.stringify({ id: requests.length, name: new URL(req.url, 'http://local').searchParams.get('name') }));
+ }
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
+
+ try {
+ const result = await spawnAsync([
+ 'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
+ '--asset', first, '--asset', second,
+ ], { STOKE_CONFIG_FILE: cfg });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Release id: 42/i);
+ assert.match(result.stdout, /Asset uploaded: first\.bin/);
+ assert.match(result.stdout, /Asset uploaded: second\.bin/);
+ assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
+ { method: 'POST', url: '/api/v1/repos/o/r/releases' },
+ { method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=first.bin' },
+ { method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=second.bin' },
+ ]);
+ assert.match(requests[1].contentType, /^multipart\/form-data; boundary=/);
+ assert.match(requests[1].body, /first payload/);
+ assert.match(requests[2].body, /second payload/);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('release upload resolves the tag once and applies a single asset name override', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-upload-'));
+ const cfg = path.join(dir, 'config.json');
+ const asset = path.join(dir, 'original.bin');
+ fs.writeFileSync(asset, 'upload payload');
+ const requests = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (chunk) => chunks.push(chunk));
+ req.on('end', () => {
+ requests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') });
+ res.setHeader('Content-Type', 'application/json');
+ if (req.method === 'GET') {
+ res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
+ } else {
+ res.writeHead(201);
+ res.end(JSON.stringify({ id: 8, name: 'renamed.bin' }));
+ }
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
+
+ try {
+ const result = await spawnAsync([
+ 'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
+ '--asset', asset, '--asset-name', 'renamed.bin',
+ ], { STOKE_CONFIG_FILE: cfg });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Asset uploaded: renamed\.bin/);
+ assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
+ { method: 'GET', url: '/api/v1/repos/o/r/releases/tags/v1' },
+ { method: 'POST', url: '/api/v1/repos/o/r/releases/7/assets?name=renamed.bin' },
+ ]);
+ assert.match(requests[1].body, /upload payload/);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('release create keeps the release and reports landed and failed assets', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-partial-'));
+ const cfg = path.join(dir, 'config.json');
+ const good = path.join(dir, 'good.bin');
+ const bad = path.join(dir, 'bad.bin');
+ fs.writeFileSync(good, 'good');
+ fs.writeFileSync(bad, 'bad');
+ const requests = [];
+ const server = http.createServer((req, res) => {
+ req.resume();
+ req.on('end', () => {
+ requests.push({ method: req.method, url: req.url });
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/api/v1/repos/o/r/releases') {
+ res.writeHead(201);
+ res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'v1', html_url: 'https://forge.test/release/v1' }));
+ } else if (req.url.includes('good.bin')) {
+ res.writeHead(201);
+ res.end(JSON.stringify({ id: 1, name: 'good.bin' }));
+ } else {
+ res.writeHead(500);
+ res.end(JSON.stringify({ message: 'storage unavailable' }));
+ }
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
+
+ try {
+ const result = await spawnAsync([
+ 'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
+ '--asset', good, '--asset', bad,
+ ], { STOKE_CONFIG_FILE: cfg });
+ assert.equal(result.status, 1);
+ assert.match(result.stdout, /Release id: 42/i);
+ assert.match(result.stdout, /Asset uploaded: good\.bin/);
+ assert.match(result.stderr, /Asset failed: bad\.bin: storage unavailable/);
+ assert.match(result.stderr, /release was kept/i);
+ assert.deepEqual(requests.map(({ url }) => url), [
+ '/api/v1/repos/o/r/releases',
+ '/api/v1/repos/o/r/releases/42/assets?name=good.bin',
+ '/api/v1/repos/o/r/releases/42/assets?name=bad.bin',
+ ]);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('repo create surfaces an organization permission failure and HTTP status', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-repo-create-403.json`);
const requests = [];