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/cli.js b/src/cli.js index e81cdef..67300c6 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1072,6 +1072,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); } diff --git a/test/cli.test.js b/test/cli.test.js index de84f8b..d201900 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -229,6 +229,44 @@ test('release create keeps the release and reports landed and failed assets', as } }); +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 = [];