diff --git a/package.json b/package.json index dc59209..c631a0b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "wiki:crawl": "node tools/wiki-crawler/index.mjs", "test:stress": "node packages/cli/tests/stress/run.mjs" }, + "dependencies": {}, "devDependencies": { "tsx": "catalog:", "vite-plus": "catalog:" diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index 53f2d59..a20117d 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -2,6 +2,7 @@ import { execSync } from "child_process"; import { writeFileSync } from "fs"; import { join } from "path"; import { + DEFAULT_INSTALL_SCRIPT_URL, defineCommand, getConfigDir, getInstallMethod, @@ -101,9 +102,7 @@ export default defineCommand({ const message = error instanceof Error ? error.message : String(error); process.stderr.write(`\nAutomatic binary update failed: ${message}\n`); process.stderr.write("Re-run the install script:\n"); - process.stderr.write( - " curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash\n\n", - ); + process.stderr.write(` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n\n`); } return; } diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index 44d23d1..d9996f4 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -1,11 +1,17 @@ /** - * End-user binary download base (OSS mirror). - * GitHub Releases remain the publish source of truth; an external FC syncs assets here. - * Production install.sh / install.ps1 are maintained outside this repo and read OSS only. + * End-user binary download base (OSS). CI publishes release assets and rolling + * channel manifests here directly (tools/release/lib/oss-direct-upload.mjs); + * no external FC is involved. + * + * Layout under the base: + * v/.zip —— immutable per-version binaries + SHA256SUMS + * manifest.json —— stable pointer { latest, releasedAt, assets } + * latest.json —— stable rolling manifest (os-arch keyed, sha256) + * .json —— per-channel rolling manifests (beta versions) * * Override with `BAILIAN_CLI_CDN`. */ -export const DEFAULT_CLI_CDN_BASE = "https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli"; +export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release"; /** GitHub Releases base — used when writing manifests attached to gh release assets. */ export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases"; @@ -20,16 +26,18 @@ export function getCliCdnBase(): string { } /** - * Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json` - * Formal installs still read `channels/latest.json` on OSS; this repo no longer - * attaches `latest.json` to GitHub Releases (FC / ops maintain OSS latest). + * Rolling channel manifest at the CDN base root: `{base}/{channel}.json`. + * `latest.json` (stable) and `.json` (betas) share the same shape; + * both are written by CI on publish. */ export function channelManifestUrl(channel = "latest"): string { - return `${getCliCdnBase()}/channels/${channel}.json`; + return `${getCliCdnBase()}/${channel}.json`; } +/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */ export function releaseAssetUrl(version: string, fileName: string): string { - return `${getCliCdnBase()}/releases/${version}/${fileName}`; + const tag = version.startsWith("v") ? version : `v${version}`; + return `${getCliCdnBase()}/${tag}/${fileName}`; } /** Platform triple used in asset names: `bl---[.exe]`. */ diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index 956d769..67a1907 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -1,6 +1,11 @@ import { join } from "path"; import { readFileSync, writeFileSync } from "fs"; -import { getConfigDir, trackingHeaders, getInstallMethod } from "bailian-cli-core"; +import { + DEFAULT_INSTALL_SCRIPT_URL, + getConfigDir, + trackingHeaders, + getInstallMethod, +} from "bailian-cli-core"; export const NPM_REGISTRY = "https://registry.npmjs.org"; /** Default npm package; products override per-call via the `npmPackage` argument. */ @@ -257,7 +262,7 @@ export async function performAutoUpdate( } catch (err) { process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`); process.stderr.write( - ` ${yellow} Re-run:${reset} ${cyan}curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash${reset}\n\n`, + ` ${yellow} Re-run:${reset} ${cyan}curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash${reset}\n\n`, ); return false; } diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 25ee40b..d92b545 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -7,8 +7,9 @@ * node tools/release/lib/binary-build.mjs --mode stable --host * * Manifests: - * --mode stable → no release manifest (latest.json removed) - * --mode channel → writes .json only + * --mode stable → writes latest.json (rolling manifest of the implicit + * "latest" channel; OSS prefix root only, not a GH asset) + * --mode channel → writes .json */ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -156,10 +157,7 @@ function writeChecksums(outdir, artifacts) { writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`); } -/** - * Channel-only: write `.json` with per-platform zip file + sha256. - * Stable no longer emits latest.json. - */ +/** Write the rolling channel manifest (`latest.json` / `.json`) with per-platform zip file + sha256. */ function writeChannelManifest(outdir, version, artifacts, channel) { const assets = Object.fromEntries( artifacts.map((item) => [ @@ -224,9 +222,9 @@ export function buildBinaryArtifacts(rawOptions = {}) { writeChecksums(outdir, artifacts); const extras = ["SHA256SUMS"]; - if (mode === "channel") { - extras.push(writeChannelManifest(outdir, version, artifacts, channel)); - } + extras.push( + writeChannelManifest(outdir, version, artifacts, mode === "channel" ? channel : "latest"), + ); log(`\nBuilt ${artifacts.length} zip(s):`); for (const item of artifacts) { diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs index f91b4f2..184a554 100644 --- a/tools/release/lib/binary-options.mjs +++ b/tools/release/lib/binary-options.mjs @@ -1,9 +1,10 @@ /** * Shared mode / channel / manifest naming for binary-build and binary-release. * - * Stable releases no longer write `latest.json` (OSS `channels/latest.json` is - * maintained outside this repo / by FC). Channel mode still writes `.json` - * onto the rolling `channel-` GitHub Release. + * Every mode writes a rolling channel manifest: stable writes `latest.json` + * (uploaded to the OSS prefix root only, never attached to the GitHub Release), + * channel writes `.json` (attached to the rolling `channel-` + * GitHub Release and uploaded to the OSS prefix root). */ import { assertChannel } from "./validate.mjs"; @@ -27,7 +28,7 @@ export function normalizeModeChannel(mode = "stable", channel = null) { return { mode: "stable", channel: null }; } -/** Channel rolling-manifest basename (`mcp.json`, …). Stable has none. */ +/** Rolling-manifest basename: `latest.json` for stable, `.json` otherwise. */ export function channelManifestFileName(channel) { if (!channel) throw new Error("channelManifestFileName requires a channel name"); return `${channel}.json`; diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index a906a51..0b1d37a 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -138,7 +138,9 @@ function ossMirrorPlans({ dir, version, mode, channel, files }) { const plans = [{ tag: `v${version}`, paths }]; if (mode === "channel") { const manifest = channelManifestFileName(channel); - plans.push({ tag: `channel-${channel}`, paths: [files ? join(dir, manifest) : manifest] }); + // Rolling channel manifest lives at the OSS prefix root, next to + // manifest.json / latest.json — one flat json per channel. + plans.push({ tag: "", paths: [files ? join(dir, manifest) : manifest] }); } return plans; } @@ -182,6 +184,7 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { await maintainReleaseManifest({ tag: `v${version}`, assetNames: plans[0].paths.map((path) => basename(path)), + channelJsonPath: null, dryRun: true, }); } @@ -198,13 +201,11 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { if (!files.includes("SHA256SUMS")) { throw new Error(`Missing SHA256SUMS in ${dir}`); } - if (mode === "channel") { - const manifestName = channelManifestFileName(channel); - if (!files.includes(manifestName)) { - throw new Error( - `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, - ); - } + const rollingManifest = channelManifestFileName(mode === "channel" ? channel : "latest"); + if (!files.includes(rollingManifest)) { + throw new Error( + `Missing ${rollingManifest} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, + ); } process.stdout.write(`artifacts in ${dir}:\n`); @@ -234,6 +235,7 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { await maintainReleaseManifest({ tag: `v${version}`, assetNames: plans[0].paths.map((path) => basename(path)), + channelJsonPath: join(dir, rollingManifest), dryRun, }); } diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index 132de8f..a821a86 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -4,13 +4,13 @@ * No external FC is involved anymore; CI is the single writer. * * Flow: - * - Every mode uploads its assets to `//` (channel - * builds include the rolling `channel-/.json`). + * - Every mode uploads its assets to `//`; rolling + * channel manifests (`.json`) go to the prefix root (empty tag). * - After upload, every object is HEAD-verified against the local byte size * (reconciliation — the runner has the ground-truth artifacts on disk). * - Stable only: when the tag is a NEWER version than manifest.latest - * (compareVersions), rewrite `/manifest.json`. Channel/prerelease - * never touches it — same semantics the FC sync-release used to enforce. + * (compareVersions), rewrite `/manifest.json` and the rolling + * `/latest.json`. Channel/prerelease never touches either. * * Zero-dependency: OSS V1 header signature (HMAC-SHA1) over plain fetch. * @@ -132,12 +132,16 @@ function buildManifest(tag, releasedAt, assetNames, cfg) { * Signed OSS request (V1 header signature). Keys here are [A-Za-z0-9._/-] only, * so no URL encoding is needed and the signed resource matches the request path. */ -async function ossRequest(method, key, { creds, cfg, body = null, contentType = "" }) { +async function ossRequest( + method, + key, + { creds, cfg, body = null, contentType = "", extraHeaders = {} }, +) { const date = new Date().toUTCString(); const contentMd5 = body ? createHash("md5").update(body).digest("base64") : ""; const canonical = `${method}\n${contentMd5}\n${contentType}\n${date}\n/${cfg.bucket}/${key}`; const signature = createHmac("sha1", creds.sk).update(canonical).digest("base64"); - const headers = { Date: date, Authorization: `OSS ${creds.ak}:${signature}` }; + const headers = { Date: date, Authorization: `OSS ${creds.ak}:${signature}`, ...extraHeaders }; if (contentType) headers["Content-Type"] = contentType; if (contentMd5) headers["Content-MD5"] = contentMd5; const options = { method, headers }; @@ -169,12 +173,23 @@ async function putWithRetry(params, attempts = 3) { } } -/** Remote object byte size; null when the object does not exist. */ +/** + * Remote object byte size; null when the object does not exist. + * Forces the identity encoding: for compressible types (e.g. JSON) OSS gzips + * the transfer and undici then strips the content-length header, which would + * otherwise read as a bogus size 0 here. + */ async function headObjectSize(key, creds, cfg) { - const res = await ossRequest("HEAD", key, { creds, cfg }); + const res = await ossRequest("HEAD", key, { + creds, + cfg, + extraHeaders: { "Accept-Encoding": "identity" }, + }); if (res.status === 404) return null; if (!res.ok) throw new Error(`OSS HEAD ${key} failed: HTTP ${res.status}`); - return Number(res.headers.get("content-length")); + const length = res.headers.get("content-length"); + if (length == null) throw new Error(`OSS HEAD ${key} returned no content-length`); + return Number(length); } /** GET + parse a JSON object; null when missing or corrupt. */ @@ -210,8 +225,12 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { } const { creds, cfg } = ctx; + // An empty tag means the object lives at the prefix root (rolling manifests). const jobs = plans.flatMap(({ tag, paths }) => - paths.map((path) => ({ path, key: `${cfg.prefix}/${tag}/${basename(path)}` })), + paths.map((path) => ({ + path, + key: [cfg.prefix, tag, basename(path)].filter(Boolean).join("/"), + })), ); if (jobs.length === 0) return { uploaded: 0, skipped: true }; @@ -273,14 +292,24 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { } /** - * Maintain `/manifest.json` for STABLE releases only: rewrite it when - * `tag` is a newer version than the current `latest` (first write included). - * Same update rule the FC sync-release used to apply. + * Maintain the STABLE pointers at the prefix root: rewrite `manifest.json` + * (and, when `channelJsonPath` is given, the rolling `latest.json`) when `tag` + * is a newer version than the current `latest` (first write included). * - * @param {{ tag: string, assetNames: string[], dryRun?: boolean }} options + * @param {{ + * tag: string, + * assetNames: string[], + * channelJsonPath?: string | null, + * dryRun?: boolean, + * }} options * @returns {Promise<{ updated: boolean, latest: string | null }>} */ -export async function maintainReleaseManifest({ tag, assetNames, dryRun = false }) { +export async function maintainReleaseManifest({ + tag, + assetNames, + channelJsonPath = null, + dryRun = false, +}) { const ctx = ossContext(); if (!ctx) { process.stdout.write("[info] BAILIAN_OSS_AK/SK unset; skip manifest.json maintenance\n"); @@ -291,7 +320,7 @@ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false if (dryRun) { process.stdout.write( - `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite when ${tag} > latest (assets: ${assetNames.length})\n`, + `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite manifest.json + latest.json when ${tag} > latest (assets: ${assetNames.length})\n`, ); return { updated: false, latest: null }; } @@ -313,5 +342,15 @@ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false contentType: "application/json", }); process.stdout.write(`manifest.json → latest=${tag} (was ${currentLatest ?? "none"})\n`); + if (channelJsonPath) { + await putObject({ + creds, + cfg, + key: `${cfg.prefix}/latest.json`, + body: readFileSync(channelJsonPath), + contentType: "application/json", + }); + process.stdout.write(`latest.json → ${tag}\n`); + } return { updated: true, latest: tag }; } diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs index 8c6f094..4b06314 100644 --- a/tools/release/lib/validate.mjs +++ b/tools/release/lib/validate.mjs @@ -54,7 +54,16 @@ export function loadAndValidatePackages({ packages } = {}) { return { coreJson, cliJson }; } -const RESERVED_CHANNELS = new Set(["latest", "beta", "alpha", "next", "rc", "canary", "dev"]); +const RESERVED_CHANNELS = new Set([ + "latest", + "manifest", // would collide with manifest.json at the OSS prefix root + "beta", + "alpha", + "next", + "rc", + "canary", + "dev", +]); const CHANNEL_FORMAT = /^[a-z][a-z0-9-]{1,30}$/; export function assertChannel(channel) {