feat: upload release assets #37
6 changed files with 505 additions and 3 deletions
38
README.md
38
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 <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`
|
||||
|
||||
|
|
|
|||
1
changelog.d/25.md
Normal file
1
changelog.d/25.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Release commands can now stream asset uploads, rename single assets, report partial failures, print release ids, and list attached files. (#25).
|
||||
61
src/api.js
61
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);
|
||||
}
|
||||
|
|
|
|||
88
src/cli.js
88
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)) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
259
test/cli.test.js
259
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 = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue