44 * No external FC is involved anymore; CI is the single writer.
55 *
66 * Flow:
7- * - Every mode uploads its assets to `<prefix>/<tag>/<basename>` (channel
8- * builds include the rolling `channel-<name>/<name> .json`).
7+ * - Every mode uploads its assets to `<prefix>/<tag>/<basename>`; rolling
8+ * channel manifests (`<channel> .json`) go to the prefix root (empty tag ).
99 * - After upload, every object is HEAD-verified against the local byte size
1010 * (reconciliation — the runner has the ground-truth artifacts on disk).
1111 * - Stable only: when the tag is a NEWER version than manifest.latest
12- * (compareVersions), rewrite `<prefix>/manifest.json`. Channel/prerelease
13- * never touches it — same semantics the FC sync-release used to enforce .
12+ * (compareVersions), rewrite `<prefix>/manifest.json` and the rolling
13+ * `<prefix>/latest.json`. Channel/prerelease never touches either .
1414 *
1515 * Zero-dependency: OSS V1 header signature (HMAC-SHA1) over plain fetch.
1616 *
@@ -132,12 +132,16 @@ function buildManifest(tag, releasedAt, assetNames, cfg) {
132132 * Signed OSS request (V1 header signature). Keys here are [A-Za-z0-9._/-] only,
133133 * so no URL encoding is needed and the signed resource matches the request path.
134134 */
135- async function ossRequest ( method , key , { creds, cfg, body = null , contentType = "" } ) {
135+ async function ossRequest (
136+ method ,
137+ key ,
138+ { creds, cfg, body = null , contentType = "" , extraHeaders = { } } ,
139+ ) {
136140 const date = new Date ( ) . toUTCString ( ) ;
137141 const contentMd5 = body ? createHash ( "md5" ) . update ( body ) . digest ( "base64" ) : "" ;
138142 const canonical = `${ method } \n${ contentMd5 } \n${ contentType } \n${ date } \n/${ cfg . bucket } /${ key } ` ;
139143 const signature = createHmac ( "sha1" , creds . sk ) . update ( canonical ) . digest ( "base64" ) ;
140- const headers = { Date : date , Authorization : `OSS ${ creds . ak } :${ signature } ` } ;
144+ const headers = { Date : date , Authorization : `OSS ${ creds . ak } :${ signature } ` , ... extraHeaders } ;
141145 if ( contentType ) headers [ "Content-Type" ] = contentType ;
142146 if ( contentMd5 ) headers [ "Content-MD5" ] = contentMd5 ;
143147 const options = { method, headers } ;
@@ -169,12 +173,23 @@ async function putWithRetry(params, attempts = 3) {
169173 }
170174}
171175
172- /** Remote object byte size; null when the object does not exist. */
176+ /**
177+ * Remote object byte size; null when the object does not exist.
178+ * Forces the identity encoding: for compressible types (e.g. JSON) OSS gzips
179+ * the transfer and undici then strips the content-length header, which would
180+ * otherwise read as a bogus size 0 here.
181+ */
173182async function headObjectSize ( key , creds , cfg ) {
174- const res = await ossRequest ( "HEAD" , key , { creds, cfg } ) ;
183+ const res = await ossRequest ( "HEAD" , key , {
184+ creds,
185+ cfg,
186+ extraHeaders : { "Accept-Encoding" : "identity" } ,
187+ } ) ;
175188 if ( res . status === 404 ) return null ;
176189 if ( ! res . ok ) throw new Error ( `OSS HEAD ${ key } failed: HTTP ${ res . status } ` ) ;
177- return Number ( res . headers . get ( "content-length" ) ) ;
190+ const length = res . headers . get ( "content-length" ) ;
191+ if ( length == null ) throw new Error ( `OSS HEAD ${ key } returned no content-length` ) ;
192+ return Number ( length ) ;
178193}
179194
180195/** GET + parse a JSON object; null when missing or corrupt. */
@@ -210,8 +225,12 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) {
210225 }
211226 const { creds, cfg } = ctx ;
212227
228+ // An empty tag means the object lives at the prefix root (rolling manifests).
213229 const jobs = plans . flatMap ( ( { tag, paths } ) =>
214- paths . map ( ( path ) => ( { path, key : `${ cfg . prefix } /${ tag } /${ basename ( path ) } ` } ) ) ,
230+ paths . map ( ( path ) => ( {
231+ path,
232+ key : [ cfg . prefix , tag , basename ( path ) ] . filter ( Boolean ) . join ( "/" ) ,
233+ } ) ) ,
215234 ) ;
216235 if ( jobs . length === 0 ) return { uploaded : 0 , skipped : true } ;
217236
@@ -273,14 +292,24 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) {
273292}
274293
275294/**
276- * Maintain `<prefix>/manifest.json` for STABLE releases only : rewrite it when
277- * `tag ` is a newer version than the current `latest` (first write included).
278- * Same update rule the FC sync-release used to apply .
295+ * Maintain the STABLE pointers at the prefix root : rewrite `manifest.json`
296+ * (and, when `channelJsonPath ` is given, the rolling `latest.json`) when `tag`
297+ * is a newer version than the current `latest` (first write included) .
279298 *
280- * @param {{ tag: string, assetNames: string[], dryRun?: boolean } } options
299+ * @param {{
300+ * tag: string,
301+ * assetNames: string[],
302+ * channelJsonPath?: string | null,
303+ * dryRun?: boolean,
304+ * }} options
281305 * @returns {Promise<{ updated: boolean, latest: string | null }> }
282306 */
283- export async function maintainReleaseManifest ( { tag, assetNames, dryRun = false } ) {
307+ export async function maintainReleaseManifest ( {
308+ tag,
309+ assetNames,
310+ channelJsonPath = null ,
311+ dryRun = false ,
312+ } ) {
284313 const ctx = ossContext ( ) ;
285314 if ( ! ctx ) {
286315 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
291320
292321 if ( dryRun ) {
293322 process . stdout . write (
294- `[dry-run] manifest: GET oss://${ cfg . bucket } /${ key } → rewrite when ${ tag } > latest (assets: ${ assetNames . length } )\n` ,
323+ `[dry-run] manifest: GET oss://${ cfg . bucket } /${ key } → rewrite manifest.json + latest.json when ${ tag } > latest (assets: ${ assetNames . length } )\n` ,
295324 ) ;
296325 return { updated : false , latest : null } ;
297326 }
@@ -313,5 +342,15 @@ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false
313342 contentType : "application/json" ,
314343 } ) ;
315344 process . stdout . write ( `manifest.json → latest=${ tag } (was ${ currentLatest ?? "none" } )\n` ) ;
345+ if ( channelJsonPath ) {
346+ await putObject ( {
347+ creds,
348+ cfg,
349+ key : `${ cfg . prefix } /latest.json` ,
350+ body : readFileSync ( channelJsonPath ) ,
351+ contentType : "application/json" ,
352+ } ) ;
353+ process . stdout . write ( `latest.json → ${ tag } \n` ) ;
354+ }
316355 return { updated : true , latest : tag } ;
317356}
0 commit comments