diff --git a/README.md b/README.md index db4d1c4..8cefd7c 100644 --- a/README.md +++ b/README.md @@ -625,7 +625,8 @@ Calls `GET /api/v1/repos/{owner}/{repo}/releases` and auto-paginates. ### `stoke release view` -Show the release for a tag, including its notes. +Show the release for a tag, including its notes and attached assets. Each asset +line includes its filename, size in bytes and download URL. ```text Options: @@ -653,15 +654,46 @@ Options: -t, --title release title (default: the tag name) -b, --body <body> release notes (markdown) --body-file <path> read the release notes from a file (wins over -b) + --asset <path> attach an asset (repeatable) + --asset-name <name> override the uploaded filename (exactly one asset) --draft create as a draft release --prerelease mark as a prerelease ``` ```bash -stoke release create -o heavy-duty -r stoke --tag v1.3.0 --body-file release-notes.md +stoke release create -o heavy-duty -r stoke --tag v1.3.0 \ + --body-file release-notes.md --asset dist/stoke_1.3.0_all.deb ``` -Calls `POST /api/v1/repos/{owner}/{repo}/releases`. +The command prints the numeric release id, tag and URL. It calls +`POST /api/v1/repos/{owner}/{repo}/releases`, then uploads each asset. If an +upload fails, the release and any assets that already landed are kept; every +asset is attempted, the command names successes and failures, and exits +non-zero. + +### `stoke release upload` + +Attach one or more assets to an existing release. `--asset-name` overrides the +uploaded filename and is valid only when exactly one `--asset` is supplied. + +```text +Options: + -o, --owner <owner> repository owner (required) + -r, --repo <repo> repository name (required) + --tag <tag> tag name of the existing release (required) + --asset <path> asset to upload (required, repeatable) + --asset-name <name> override the uploaded filename (exactly one asset) +``` + +```bash +stoke release upload -o heavy-duty -r stoke --tag v1.3.0 \ + --asset dist/checksums.txt --asset dist/stoke_1.3.0_all.deb +``` + +The command resolves the tag once with +`GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`, then uploads each file +to the release's numeric-id asset endpoint. It attempts every asset and exits +non-zero if any upload fails. ### `stoke label list` diff --git a/changelog.d/25.md b/changelog.d/25.md new file mode 100644 index 0000000..e713588 --- /dev/null +++ b/changelog.d/25.md @@ -0,0 +1 @@ +- Release commands can now stream asset uploads, rename single assets, report partial failures, print release ids, and list attached files. (#25). diff --git a/src/api.js b/src/api.js index e493fb9..b88566f 100644 --- a/src/api.js +++ b/src/api.js @@ -12,11 +12,15 @@ */ const pkg = require('../package.json'); +const fs = require('node:fs'); const REQUEST_TIMEOUT_MS = 30000; // Repository migrations clone the full source repository and can legitimately // take minutes, so they get a much longer budget. const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000; +// Release assets can be much larger than JSON API payloads, so uploads get a +// separate budget while retaining the standard timeout for ordinary calls. +const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; class ForgejoClient { constructor(baseUrl, token = null) { @@ -94,6 +98,47 @@ class ForgejoClient { return data; } + async uploadRequest(endpoint, form, { timeout = UPLOAD_TIMEOUT_MS } = {}) { + const url = `${this.baseUrl}/api/v1${endpoint}`; + const headers = this.headers(); + delete headers['Content-Type']; + + let res; + try { + res = await fetch(url, { + method: 'POST', + headers, + body: form, + signal: AbortSignal.timeout(timeout), + }); + } catch (err) { + if (err.name === 'TimeoutError') { + throw new Error(`Upload to ${this.baseUrl} timed out after ${timeout / 1000}s`); + } + throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`); + } + + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + + if (!res.ok) { + const msg = data?.message || data?.raw || `HTTP ${res.status}`; + const err = new Error(msg); + err.status = res.status; + err.body = data; + throw err; + } + + return data; + } + get(endpoint) { return this.request('GET', endpoint); } @@ -229,6 +274,22 @@ class ForgejoClient { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload); } + async uploadReleaseAsset(owner, repo, releaseId, filePath, name) { + const form = new FormData(); + let file; + try { + file = await fs.openAsBlob(filePath); + } catch (err) { + throw new Error(`Could not read asset file ${filePath}: ${err.message}`); + } + form.append('attachment', file, name); + const query = new URLSearchParams({ name }); + return this.uploadRequest( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?${query}`, + form, + ); + } + async listLabels(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts); } diff --git a/src/cli.js b/src/cli.js index 1c5713f..9c8cfbd 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,36 @@ function parseDepth(value) { return n; } +function collectOption(value, previous) { + return previous.concat(value); +} + +function validateAssetOptions(options, { requireAsset = false } = {}) { + if (requireAsset && options.asset.length === 0) { + throw new Error('At least one --asset is required.'); + } + 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) { @@ -1044,6 +1075,12 @@ release console.log(`Target: ${rel.target_commitish}`); console.log(`Author: ${rel.author?.login || '(unknown)'}`); console.log(`Published: ${rel.published_at}`); + if (rel.assets?.length) { + console.log('\nAssets:'); + for (const asset of rel.assets) { + console.log(` ${asset.name} (${asset.size} bytes) ${asset.browser_download_url}`); + } + } if (rel.body) { console.log('\n' + rel.body); } @@ -1064,10 +1101,13 @@ release .option('-t, --title <title>', 'release title (default: the tag name)') .option('-b, --body <body>', 'release notes (markdown)') .option('--body-file <path>', 'read the release notes from a file (wins over -b)') + .option('--asset <path>', 'attach an asset (repeatable)', collectOption, []) + .option('--asset-name <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 +1120,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 +1143,39 @@ release } }); +release + .command('upload') + .description('Attach assets to an existing release') + .requiredOption('-o, --owner <owner>', 'repository owner') + .requiredOption('-r, --repo <repo>', 'repository name') + .requiredOption('--tag <tag>', 'tag name of the release') + .requiredOption('--asset <path>', 'asset to upload (repeatable)', collectOption, []) + .option('--asset-name <name>', 'override the uploaded filename (exactly one asset)') + .action(async (options) => { + try { + validateAssetOptions(options, { requireAsset: true }); + 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/api.test.js b/test/api.test.js index 9b517f7..aa0e461 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -1,13 +1,18 @@ const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const { ForgejoClient } = require('../src/api'); const pkg = require('../package.json'); const realFetch = global.fetch; +const realAbortTimeout = AbortSignal.timeout; afterEach(() => { global.fetch = realFetch; + AbortSignal.timeout = realAbortTimeout; }); function mockFetch(handler) { @@ -278,6 +283,62 @@ test('release endpoints map to the expected URLs and payloads', async () => { assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0'); }); +test('uploadReleaseAsset streams multipart data without forcing a JSON content type', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-api-')); + const assetPath = path.join(dir, 'artifact.bin'); + fs.writeFileSync(assetPath, 'asset bytes'); + const calls = mockFetch(() => jsonResponse({ id: 9, name: 'custom name.bin' }, 201)); + const client = new ForgejoClient('https://forge.test', 'tok'); + + try { + await client.uploadReleaseAsset('heavy duty', 'stoke', 42, assetPath, 'custom name.bin'); + const { url, opts } = calls[0]; + assert.equal(url, 'https://forge.test/api/v1/repos/heavy%20duty/stoke/releases/42/assets?name=custom+name.bin'); + assert.equal(opts.method, 'POST'); + assert.equal(opts.headers.Authorization, 'token tok'); + assert.equal(opts.headers['Content-Type'], undefined); + const attachment = opts.body.get('attachment'); + assert.equal(attachment.name, 'custom name.bin'); + assert.equal(await attachment.text(), 'asset bytes'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('uploadReleaseAsset identifies a missing local asset path', async () => { + const assetPath = path.join(os.tmpdir(), `stoke-missing-asset-${process.pid}.bin`); + const client = new ForgejoClient('https://forge.test', 'tok'); + + await assert.rejects( + client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'artifact.bin'), + (err) => { + assert.match(err.message, /Could not read asset file/); + assert.match(err.message, new RegExp(assetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + return true; + }, + ); +}); + +test('uploadReleaseAsset uses the upload timeout instead of the 30 second JSON timeout', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-timeout-')); + const assetPath = path.join(dir, 'large.bin'); + fs.writeFileSync(assetPath, 'content'); + let timeout; + AbortSignal.timeout = (milliseconds) => { + timeout = milliseconds; + return new AbortController().signal; + }; + mockFetch(() => jsonResponse({ id: 10 }, 201)); + const client = new ForgejoClient('https://forge.test', 'tok'); + + try { + await client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'large.bin'); + assert.equal(timeout, 10 * 60 * 1000); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('label endpoints map to the expected URLs and payloads', async () => { const calls = mockFetch(() => jsonResponse({ id: 3 })); const client = new ForgejoClient('https://forge.test', 'tok'); diff --git a/test/cli.test.js b/test/cli.test.js index d503850..817cc06 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -62,6 +62,265 @@ test('repo create help lists the owner option', () => { assert.match(res.stdout, /-o, --owner <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 <path>/); + assert.match(create.stdout, /--asset-name <name>/); + + const upload = run(['release', 'upload', '--help']); + assert.equal(upload.status, 0, upload.stderr); + assert.match(upload.stdout, /--tag <tag>/); + assert.match(upload.stdout, /--asset <path>/); + assert.match(upload.stdout, /--asset-name <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 upload rejects zero assets before reading config', () => { + const res = run([ + 'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1', + ], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) }); + assert.equal(res.status, 1); + assert.match(res.stderr, /at least one --asset is required/i); + 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 upload streams a large asset through receiver backpressure', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-large-upload-')); + const cfg = path.join(dir, 'config.json'); + const asset = path.join(dir, 'large.bin'); + const assetSize = 8 * 1024 * 1024; + fs.writeFileSync(asset, Buffer.alloc(assetSize, 0x61)); + let uploadedBytes = 0; + let paused = false; + const server = http.createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 7, tag_name: 'v1' })); + return; + } + req.on('data', (chunk) => { + uploadedBytes += chunk.length; + if (!paused) { + paused = true; + req.pause(); + setTimeout(() => req.resume(), 100); + } + }); + req.on('end', () => { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 8, name: 'large.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], + { STOKE_CONFIG_FILE: cfg }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(paused, true); + assert.ok(uploadedBytes > assetSize, `multipart body ${uploadedBytes} did not include ${assetSize} asset bytes`); + assert.match(result.stdout, /Asset uploaded: large\.bin/); + } 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', bad, '--asset', good, + ], { 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=bad.bin', + '/api/v1/repos/o/r/releases/42/assets?name=good.bin', + ]); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('release view lists attached assets with their sizes and download URLs', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-view-assets-')); + const cfg = path.join(dir, 'config.json'); + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 42, + tag_name: 'v1', + name: 'Version 1', + html_url: 'https://forge.test/o/r/releases/v1', + target_commitish: 'main', + author: { login: 'bot' }, + published_at: '2026-08-30T00:00:00Z', + body: '', + assets: [ + { name: 'first.bin', size: 12, browser_download_url: 'https://forge.test/assets/first.bin' }, + { name: 'second.bin', size: 2048, browser_download_url: 'https://forge.test/assets/second.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', 'view', '-o', 'o', '-r', 'r', '--tag', 'v1'], + { STOKE_CONFIG_FILE: cfg }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Assets:/); + assert.match(result.stdout, /first\.bin \(12 bytes\) https:\/\/forge\.test\/assets\/first\.bin/); + assert.match(result.stdout, /second\.bin \(2048 bytes\) https:\/\/forge\.test\/assets\/second\.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 = [];