fix(release): repair agent mirror synchronization
This commit is contained in:
parent
84875c7cd4
commit
71497f0851
|
|
@ -3,6 +3,7 @@
|
|||
import { basename, join } from "node:path";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5";
|
||||
const DEFAULT_REPOSITORY = "t8y2/dbx";
|
||||
|
|
@ -62,7 +63,8 @@ async function main() {
|
|||
|
||||
const client = new AtomGitClient({ apiBase: args.apiBase, token: args.token, owner, repo });
|
||||
const atomRelease = await ensureRelease(client, release);
|
||||
const existingAssets = atomGitAssetNames(atomRelease);
|
||||
// PATCH responses may omit attachments, so re-read before deciding what must be replaced.
|
||||
const existingAssets = atomGitAssets((await client.getRelease(tag)) || atomRelease);
|
||||
const assets = localAssets(args.assetsDir);
|
||||
if (assets.length === 0) {
|
||||
console.log("No release assets found to sync.");
|
||||
|
|
@ -78,8 +80,28 @@ async function main() {
|
|||
return true;
|
||||
});
|
||||
|
||||
if (!args.skipExistingAssets) {
|
||||
for (const assetPath of pendingAssets) {
|
||||
const name = basename(assetPath);
|
||||
const existingAsset = existingAssets.get(name);
|
||||
if (!existingAsset) continue;
|
||||
if (existingAsset.id === null) {
|
||||
throw new Error(`Cannot replace AtomGit asset without an attachment id: ${name}`);
|
||||
}
|
||||
console.log(`Deleting existing AtomGit asset: ${name}`);
|
||||
await client.deleteAsset(tag, existingAsset.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploading ${pendingAssets.length} asset(s) with concurrency ${args.concurrency}.`);
|
||||
await mapWithConcurrency(pendingAssets, args.concurrency, (assetPath) => uploadAsset(client, tag, assetPath));
|
||||
|
||||
const refreshedRelease = await client.getRelease(tag);
|
||||
const uploadedAssets = atomGitAssets(refreshedRelease);
|
||||
const missingAssets = pendingAssets.map(basename).filter((name) => !uploadedAssets.has(name));
|
||||
if (missingAssets.length > 0) {
|
||||
throw new Error(`AtomGit did not register uploaded assets: ${missingAssets.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRelease(client, githubRelease) {
|
||||
|
|
@ -121,9 +143,10 @@ async function uploadAsset(client, tag, filePath) {
|
|||
|
||||
// AtomGit returns a per-file upload URL plus required upload headers.
|
||||
// Try raw PUT first and fall back to multipart POST for API-compatible deployments.
|
||||
const putStatus = await runCurl([
|
||||
const putResult = await runCurl([
|
||||
"--fail-with-body",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
|
|
@ -138,14 +161,15 @@ async function uploadAsset(client, tag, filePath) {
|
|||
filePath,
|
||||
uploadTarget.url,
|
||||
]);
|
||||
if (putStatus === 0) {
|
||||
if (uploadResultSucceeded(putResult)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`);
|
||||
const postStatus = await runCurl([
|
||||
console.warn(`PUT upload failed for ${name}: ${uploadFailureMessage(putResult)}; retrying as multipart POST.`);
|
||||
const postResult = await runCurl([
|
||||
"--fail-with-body",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
|
|
@ -158,19 +182,45 @@ async function uploadAsset(client, tag, filePath) {
|
|||
`file=@${filePath};filename=${name};type=${contentTypeHeader}`,
|
||||
uploadTarget.url,
|
||||
]);
|
||||
if (postStatus !== 0) {
|
||||
throw new Error(`Failed to upload ${name} to AtomGit.`);
|
||||
if (!uploadResultSucceeded(postResult)) {
|
||||
throw new Error(`Failed to upload ${name} to AtomGit: ${uploadFailureMessage(postResult)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runCurl(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("curl", args, { stdio: "inherit" });
|
||||
const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => resolve(code ?? 1));
|
||||
child.once("close", (code) => resolve({ status: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadResultSucceeded(result) {
|
||||
if (result.status !== 0) return false;
|
||||
const responseBody = result.stdout.trim();
|
||||
if (!responseBody || responseBody.toLowerCase() === "success") return true;
|
||||
try {
|
||||
const response = JSON.parse(responseBody);
|
||||
return response.success === true || response.status === "success";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFailureMessage(result) {
|
||||
return result.stderr.trim() || result.stdout.trim() || `curl exited with status ${result.status}`;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, worker) {
|
||||
let nextIndex = 0;
|
||||
async function runWorker() {
|
||||
|
|
@ -189,8 +239,8 @@ function localAssets(dir) {
|
|||
.sort((a, b) => statSync(a).size - statSync(b).size || basename(a).localeCompare(basename(b)));
|
||||
}
|
||||
|
||||
function atomGitAssetNames(release) {
|
||||
const names = new Set();
|
||||
export function atomGitAssets(release) {
|
||||
const assets = new Map();
|
||||
const groups = [
|
||||
release?.assets,
|
||||
release?.attach_files,
|
||||
|
|
@ -205,11 +255,14 @@ function atomGitAssetNames(release) {
|
|||
for (const item of group) {
|
||||
const name = item?.name || item?.file_name || item?.filename || item?.title;
|
||||
if (name) {
|
||||
names.add(name);
|
||||
assets.set(name, {
|
||||
id: Number.isInteger(item?.id) ? item.id : null,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
return assets;
|
||||
}
|
||||
|
||||
function contentTypeFor(name) {
|
||||
|
|
@ -230,7 +283,7 @@ function headerValue(headers, name) {
|
|||
return entry ? entry[1] : "";
|
||||
}
|
||||
|
||||
class AtomGitClient {
|
||||
export class AtomGitClient {
|
||||
constructor({ apiBase, token, owner, repo }) {
|
||||
this.apiBase = apiBase.replace(/\/+$/, "");
|
||||
this.token = token;
|
||||
|
|
@ -284,6 +337,13 @@ class AtomGitClient {
|
|||
};
|
||||
}
|
||||
|
||||
async deleteAsset(tag, assetId) {
|
||||
await this.request(
|
||||
"DELETE",
|
||||
`/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}/attach_files/${assetId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async request(method, path, body = null, requestOptions = {}) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
|
|
@ -337,7 +397,9 @@ function sanitizeUrlForLog(value) {
|
|||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import { AtomGitClient, atomGitAssets, uploadResultSucceeded } from "./sync-atomgit-release.mjs";
|
||||
|
||||
test("uploadResultSucceeded rejects AtomGit callback errors returned with HTTP success", () => {
|
||||
assert.equal(uploadResultSucceeded({ status: 0, stdout: "success", stderr: "" }), true);
|
||||
assert.equal(
|
||||
uploadResultSucceeded({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ message: "Fail to read response body", code: "CallBack.0002" }),
|
||||
stderr: "",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("atomGitAssets preserves attachment ids required for replacement", () => {
|
||||
assert.deepEqual(
|
||||
atomGitAssets({ assets: [{ id: 118266, name: "agent-registry.json" }] }),
|
||||
new Map([["agent-registry.json", { id: 118266, name: "agent-registry.json" }]]),
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteAsset removes an existing release attachment by id", async (t) => {
|
||||
const requests = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ method: request.method, url: request.url });
|
||||
response.statusCode = 204;
|
||||
response.end();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))));
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const client = new AtomGitClient({
|
||||
apiBase: `http://127.0.0.1:${address.port}`,
|
||||
token: "test-token",
|
||||
owner: "t8y2",
|
||||
repo: "dbx",
|
||||
});
|
||||
|
||||
await client.deleteAsset("agents-latest", 118266);
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
method: "DELETE",
|
||||
url: "/repos/t8y2/dbx/releases/agents-latest/attach_files/118266",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_API_BASE = "https://api.cnb.cool";
|
||||
const DEFAULT_REPOSITORY = "dbxio.com/dbx";
|
||||
|
|
@ -76,7 +77,7 @@ async function uploadWithRetry(client, releaseId, filePath, overwriteExisting) {
|
|||
}
|
||||
}
|
||||
|
||||
class CnbClient {
|
||||
export class CnbClient {
|
||||
constructor({ apiBase, repository, token }) {
|
||||
this.apiBase = apiBase.replace(/\/+$/, "");
|
||||
this.repository = repository;
|
||||
|
|
@ -135,7 +136,14 @@ class CnbClient {
|
|||
});
|
||||
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();
|
||||
const responseBody = await response.text();
|
||||
// CNB may acknowledge release metadata updates with HTTP 200 and an empty body.
|
||||
if (!responseBody.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(responseBody);
|
||||
} catch (error) {
|
||||
throw new Error(`CNB API ${method} ${path} returned invalid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
headers(json = false) {
|
||||
|
|
@ -165,7 +173,9 @@ async function mapWithConcurrency(items, concurrency, worker) {
|
|||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import { CnbClient } from "./sync-cnb-release.mjs";
|
||||
|
||||
test("ensureRelease accepts an empty successful PATCH response", async (t) => {
|
||||
const release = { id: "release-1", tag_name: "agents-latest", assets: [] };
|
||||
const requests = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ method: request.method, url: request.url });
|
||||
if (request.method === "GET") {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify(release));
|
||||
return;
|
||||
}
|
||||
if (request.method === "PATCH") {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.statusCode = 500;
|
||||
response.end();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))));
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const client = new CnbClient({
|
||||
apiBase: `http://127.0.0.1:${address.port}`,
|
||||
repository: "dbxio.com/dbx",
|
||||
token: "test-token",
|
||||
});
|
||||
|
||||
const result = await client.ensureRelease("agents-latest", {
|
||||
name: "Latest agents",
|
||||
body: "Latest stable agent release",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, release);
|
||||
assert.deepEqual(requests, [
|
||||
{ method: "GET", url: "/dbxio.com/dbx/-/releases/tags/agents-latest" },
|
||||
{ method: "PATCH", url: "/dbxio.com/dbx/-/releases/release-1" },
|
||||
]);
|
||||
});
|
||||
|
|
@ -71,7 +71,7 @@ pub mod xlsx_export;
|
|||
pub const R2_CDN_BASE: &str = "https://dl.dbxio.com/";
|
||||
pub const GITHUB_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/t8y2/dbx/releases/download/";
|
||||
pub const CNB_RELEASE_DOWNLOAD_PREFIX: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/download/";
|
||||
pub const ATOMGIT_RELEASE_DOWNLOAD_PREFIX: &str = "https://atomgit.com/t8y2/dbx/releases/download/";
|
||||
pub const ATOMGIT_RELEASE_API_PREFIX: &str = "https://api.atomgit.com/api/v5/repos/t8y2/dbx/releases/";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
|
@ -86,18 +86,30 @@ impl DownloadSource {
|
|||
pub fn download_candidate_urls(self, github_url: &str, r2_path: &str) -> Result<Vec<String>, String> {
|
||||
match self {
|
||||
Self::Official => Ok(download_candidate_urls(github_url, r2_path)),
|
||||
Self::Cnb => Ok(vec![
|
||||
Self::Cnb => Ok(mirror_download_candidate_urls(
|
||||
github_url,
|
||||
r2_path,
|
||||
rewrite_github_release_url(github_url, CNB_RELEASE_DOWNLOAD_PREFIX)?,
|
||||
format!("{R2_CDN_BASE}{r2_path}"),
|
||||
]),
|
||||
Self::Atomgit => Ok(vec![
|
||||
rewrite_github_release_url(github_url, ATOMGIT_RELEASE_DOWNLOAD_PREFIX)?,
|
||||
format!("{R2_CDN_BASE}{r2_path}"),
|
||||
]),
|
||||
)),
|
||||
Self::Atomgit => Ok(mirror_download_candidate_urls(
|
||||
github_url,
|
||||
r2_path,
|
||||
rewrite_github_release_url_for_atomgit(github_url)?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_download_candidate_urls(github_url: &str, r2_path: &str, mirror_url: String) -> Vec<String> {
|
||||
let r2_url = format!("{R2_CDN_BASE}{r2_path}");
|
||||
// Mutable mirror aliases can lag even when versioned release assets are healthy.
|
||||
if github_url.ends_with("/agents-latest/agent-registry.json") {
|
||||
vec![r2_url, mirror_url]
|
||||
} else {
|
||||
vec![mirror_url, r2_url]
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_github_release_url(url: &str, target_prefix: &str) -> Result<String, String> {
|
||||
if url.starts_with(target_prefix) {
|
||||
return Ok(url.to_string());
|
||||
|
|
@ -107,6 +119,20 @@ fn rewrite_github_release_url(url: &str, target_prefix: &str) -> Result<String,
|
|||
.ok_or_else(|| format!("Unsupported DBX release download URL: {url}"))
|
||||
}
|
||||
|
||||
fn rewrite_github_release_url_for_atomgit(url: &str) -> Result<String, String> {
|
||||
if url.starts_with(ATOMGIT_RELEASE_API_PREFIX) {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
let path = url
|
||||
.strip_prefix(GITHUB_RELEASE_DOWNLOAD_PREFIX)
|
||||
.ok_or_else(|| format!("Unsupported DBX release download URL: {url}"))?;
|
||||
let (tag, file_name) = path
|
||||
.split_once('/')
|
||||
.filter(|(_, file_name)| !file_name.contains('/'))
|
||||
.ok_or_else(|| format!("Unsupported DBX release asset path: {path}"))?;
|
||||
Ok(format!("{ATOMGIT_RELEASE_API_PREFIX}{tag}/attach_files/{file_name}/download"))
|
||||
}
|
||||
|
||||
pub fn download_candidate_urls(github_url: &str, r2_path: &str) -> Vec<String> {
|
||||
vec![format!("{R2_CDN_BASE}{r2_path}"), github_url.to_string()]
|
||||
}
|
||||
|
|
@ -168,20 +194,32 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_download_candidates_rewrite_release_urls() {
|
||||
fn mirror_download_candidates_prefer_stable_registry_metadata() {
|
||||
let github_url = "https://github.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json";
|
||||
assert_eq!(
|
||||
DownloadSource::Cnb.download_candidate_urls(github_url, "agents/agent-registry.json").unwrap(),
|
||||
vec![
|
||||
"https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-latest/agent-registry.json",
|
||||
"https://dl.dbxio.com/agents/agent-registry.json",
|
||||
"https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-latest/agent-registry.json",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
DownloadSource::Atomgit.download_candidate_urls(github_url, "agents/agent-registry.json").unwrap(),
|
||||
vec![
|
||||
"https://atomgit.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json",
|
||||
"https://dl.dbxio.com/agents/agent-registry.json",
|
||||
"https://api.atomgit.com/api/v5/repos/t8y2/dbx/releases/agents-latest/attach_files/agent-registry.json/download",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomgit_versioned_assets_use_attachment_download_api_first() {
|
||||
let github_url = "https://github.com/t8y2/dbx/releases/download/agents-v0.2.55/dbx-agent-h2.jar";
|
||||
assert_eq!(
|
||||
DownloadSource::Atomgit.download_candidate_urls(github_url, "agents/dbx-agent-h2.jar").unwrap(),
|
||||
vec![
|
||||
"https://api.atomgit.com/api/v5/repos/t8y2/dbx/releases/agents-v0.2.55/attach_files/dbx-agent-h2.jar/download",
|
||||
"https://dl.dbxio.com/agents/dbx-agent-h2.jar",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue