168 lines
6.2 KiB
JavaScript
168 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
|
|
const DEFAULT_API_BASE = "https://api.cnb.cool";
|
|
const DEFAULT_REPOSITORY = "dbxio.com/dbx";
|
|
const DEFAULT_CONCURRENCY = 3;
|
|
const MAX_ATTEMPTS = 4;
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const githubRelease = JSON.parse(readFileSync(args.githubReleasePath, "utf8"));
|
|
const tag = githubRelease.tagName || githubRelease.tag_name;
|
|
if (!tag) throw new Error("GitHub release JSON is missing tagName.");
|
|
|
|
const client = new CnbClient(args);
|
|
const release = await client.ensureRelease(tag, githubRelease);
|
|
const existingAssets = new Set((release.assets || []).map((asset) => asset.name));
|
|
const assets = localAssets(args.assetsDir).filter((assetPath) => {
|
|
const name = basename(assetPath);
|
|
if (existingAssets.has(name)) {
|
|
console.log(`Skipping existing CNB asset: ${name}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
|
|
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath));
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
apiBase: process.env.CNB_API_BASE || DEFAULT_API_BASE,
|
|
repository: process.env.CNB_REPOSITORY || DEFAULT_REPOSITORY,
|
|
token: process.env.CNB_TOKEN || "",
|
|
concurrency: Number.parseInt(process.env.CNB_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10),
|
|
githubReleasePath: "",
|
|
assetsDir: "",
|
|
};
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const arg = argv[index];
|
|
if (arg === "--github-release") args.githubReleasePath = argv[++index];
|
|
else if (arg === "--assets-dir") args.assetsDir = argv[++index];
|
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
if (!args.token) throw new Error("CNB_TOKEN is required.");
|
|
if (!args.githubReleasePath || !args.assetsDir) {
|
|
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> --assets-dir <dir>");
|
|
}
|
|
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
|
|
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
|
|
}
|
|
return args;
|
|
}
|
|
|
|
async function uploadWithRetry(client, releaseId, filePath) {
|
|
const name = basename(filePath);
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
console.log(`Uploading ${name}, attempt ${attempt}/${MAX_ATTEMPTS}.`);
|
|
await client.uploadAsset(releaseId, filePath);
|
|
console.log(`Uploaded ${name}.`);
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === MAX_ATTEMPTS) throw error;
|
|
const delayMs = 2 ** (attempt - 1) * 5000;
|
|
console.warn(`Upload failed for ${name}: ${error.message}; retrying in ${delayMs / 1000}s.`);
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
}
|
|
|
|
class CnbClient {
|
|
constructor({ apiBase, repository, token }) {
|
|
this.apiBase = apiBase.replace(/\/+$/, "");
|
|
this.repository = repository;
|
|
this.token = token;
|
|
}
|
|
|
|
async ensureRelease(tag, githubRelease) {
|
|
const payload = {
|
|
tag_name: tag,
|
|
name: githubRelease.name || tag,
|
|
body: githubRelease.body || "",
|
|
prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease),
|
|
target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "",
|
|
};
|
|
const existing = await this.request("GET", `/${this.repository}/-/releases/tags/${encodeURIComponent(tag)}`, null, true);
|
|
if (!existing) return this.request("POST", `/${this.repository}/-/releases`, payload);
|
|
|
|
// Keep release metadata aligned while preserving the existing asset list used for resumable uploads.
|
|
await this.request("PATCH", `/${this.repository}/-/releases/${existing.id}`, {
|
|
name: payload.name,
|
|
body: payload.body,
|
|
});
|
|
return existing;
|
|
}
|
|
|
|
async uploadAsset(releaseId, filePath) {
|
|
const size = statSync(filePath).size;
|
|
const target = await this.request("POST", `/${this.repository}/-/releases/${releaseId}/asset-upload-url`, {
|
|
asset_name: basename(filePath),
|
|
size,
|
|
overwrite: false,
|
|
});
|
|
const uploadResponse = await fetch(target.upload_url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream", "Content-Length": `${size}` },
|
|
body: createReadStream(filePath),
|
|
duplex: "half",
|
|
});
|
|
if (!uploadResponse.ok) {
|
|
throw new Error(`CNB object upload failed with ${uploadResponse.status}: ${await uploadResponse.text()}`);
|
|
}
|
|
const verifyResponse = await fetch(decodeURIComponent(target.verify_url), {
|
|
method: "POST",
|
|
headers: this.headers(),
|
|
});
|
|
if (!verifyResponse.ok) {
|
|
throw new Error(`CNB upload confirmation failed with ${verifyResponse.status}: ${await verifyResponse.text()}`);
|
|
}
|
|
}
|
|
|
|
async request(method, path, body = null, allow404 = false) {
|
|
const response = await fetch(`${this.apiBase}${path}`, {
|
|
method,
|
|
headers: this.headers(body !== null),
|
|
body: body === null ? undefined : JSON.stringify(body),
|
|
});
|
|
if (allow404 && response.status === 404) return null;
|
|
if (!response.ok) throw new Error(`CNB API ${method} ${path} failed with ${response.status}: ${await response.text()}`);
|
|
return response.status === 204 ? null : response.json();
|
|
}
|
|
|
|
headers(json = false) {
|
|
return {
|
|
Accept: "application/vnd.cnb.api+json",
|
|
Authorization: `Bearer ${this.token}`,
|
|
...(json ? { "Content-Type": "application/json" } : {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
function localAssets(dir) {
|
|
return readdirSync(dir)
|
|
.map((name) => join(dir, name))
|
|
.filter((path) => statSync(path).isFile())
|
|
.sort((left, right) => statSync(right).size - statSync(left).size || basename(left).localeCompare(basename(right)));
|
|
}
|
|
|
|
async function mapWithConcurrency(items, concurrency, worker) {
|
|
let nextIndex = 0;
|
|
async function runWorker() {
|
|
while (nextIndex < items.length) {
|
|
const index = nextIndex++;
|
|
await worker(items[index]);
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|