Skip to content

Commit 8a6dd85

Browse files
authored
Merge pull request #221 from modelstudioai/fix/fc-oidc-token-refresh
fix(release): refresh OIDC token for FC uploads
2 parents a5a68b8 + c3188cb commit 8a6dd85

2 files changed

Lines changed: 116 additions & 43 deletions

File tree

‎tools/release/lib/oss-direct-upload.mjs‎

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@
3838
import { readFileSync, statSync } from "node:fs";
3939
import { basename } from "node:path";
4040

41+
const MAX_RETRIES = 20;
42+
const ATTEMPT_TIMEOUT_MS = 600_000;
43+
const RETRY_BACKOFF_CAP_MS = 10_000;
44+
45+
function retryDelayMs(failedAttempt) {
46+
return Math.min(1000 * 2 ** (failedAttempt - 1), RETRY_BACKOFF_CAP_MS);
47+
}
48+
4149
/**
4250
* Resolve the FC release channel context; null when the channel is disabled.
4351
* Reuses the shared FC_TRIGGER_URL (the same function already serves
@@ -78,7 +86,10 @@ async function fetchOidcToken(audience) {
7886
const url = audience
7987
? `${requestUrl}${separator}audience=${encodeURIComponent(audience)}`
8088
: requestUrl;
81-
const res = await fetch(url, { headers: { Authorization: `bearer ${requestToken}` } });
89+
const res = await fetch(url, {
90+
headers: { Authorization: `bearer ${requestToken}` },
91+
signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
92+
});
8293
if (!res.ok) {
8394
throw new Error(`GitHub OIDC token request failed: HTTP ${res.status}`);
8495
}
@@ -90,21 +101,22 @@ async function fetchOidcToken(audience) {
90101
}
91102

92103
/**
93-
* Call an FC release action with the OIDC token. Retries transient network
94-
* failures; throws on HTTP errors and on `success: false` responses
95-
* (FC returns structured errors, e.g. OIDC verification failures).
104+
* Call an FC release action with a fresh OIDC token for every attempt. Retries
105+
* transient network failures; throws on HTTP errors and on `success: false`
106+
* responses (FC returns structured errors, e.g. OIDC verification failures).
96107
*/
97-
async function fcCall(ctx, action, payload, token, attempts = 3) {
108+
async function fcCall(ctx, action, payload, maxRetries = MAX_RETRIES) {
98109
for (let attempt = 1; ; attempt++) {
99110
try {
111+
const token = await fetchOidcToken(ctx.audience);
100112
const res = await fetch(`${ctx.triggerUrl}/${action}`, {
101113
method: "POST",
102114
headers: {
103115
"Content-Type": "application/json",
104116
Authorization: `Bearer ${token}`,
105117
},
106118
body: JSON.stringify(payload),
107-
signal: AbortSignal.timeout(120_000),
119+
signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
108120
});
109121
const body = await res.json().catch(() => null);
110122
if (!res.ok || !body?.success) {
@@ -119,10 +131,10 @@ async function fcCall(ctx, action, payload, token, attempts = 3) {
119131
// raw network failures, which surface as TypeError "fetch failed".
120132
const isNetworkError =
121133
error instanceof TypeError || /fetch failed|timeout/i.test(error.message);
122-
if (attempt >= attempts || !isNetworkError) throw error;
123-
const delay = 1000 * 2 ** (attempt - 1);
134+
if (attempt > maxRetries || !isNetworkError) throw error;
135+
const delay = retryDelayMs(attempt);
124136
process.stdout.write(
125-
` [fc] retry ${attempt}/${attempts - 1} for ${action} in ${delay}ms (${error.message})\n`,
137+
` [fc] retry ${attempt}/${maxRetries} for ${action} in ${delay}ms (${error.message})\n`,
126138
);
127139
await new Promise((resolve) => setTimeout(resolve, delay));
128140
}
@@ -155,7 +167,7 @@ async function runWithConcurrency(tasks, limit) {
155167
}
156168

157169
/** PUT a local file to a presigned URL with exponential-backoff retries. */
158-
async function putWithRetry({ putUrl, contentType, body }, attempts = 3) {
170+
async function putWithRetry({ putUrl, contentType, body }, maxRetries = MAX_RETRIES) {
159171
for (let attempt = 1; ; attempt++) {
160172
try {
161173
// Only Content-Type was signed by FC; sending extra canonical headers
@@ -164,18 +176,18 @@ async function putWithRetry({ putUrl, contentType, body }, attempts = 3) {
164176
method: "PUT",
165177
headers: { "Content-Type": contentType },
166178
body,
167-
signal: AbortSignal.timeout(600_000),
179+
signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
168180
});
169181
if (!res.ok) {
170182
const text = await res.text().catch(() => "");
171183
throw new Error(`HTTP ${res.status} ${text.slice(0, 200)}`);
172184
}
173185
return;
174186
} catch (error) {
175-
if (attempt >= attempts) throw error;
176-
const delay = 1000 * 2 ** (attempt - 1);
187+
if (attempt > maxRetries) throw error;
188+
const delay = retryDelayMs(attempt);
177189
process.stdout.write(
178-
` [oss] retry ${attempt}/${attempts - 1} in ${delay}ms (${error.message})\n`,
190+
` [oss] retry ${attempt}/${maxRetries} in ${delay}ms (${error.message})\n`,
179191
);
180192
await new Promise((resolve) => setTimeout(resolve, delay));
181193
}
@@ -194,20 +206,14 @@ async function putWithRetry({ putUrl, contentType, body }, attempts = 3) {
194206
* }} params
195207
*/
196208
async function uploadViaFc({ ctx, prefix, jobs, label }) {
197-
const token = await fetchOidcToken(ctx.audience);
198-
const prepare = await fcCall(
199-
ctx,
200-
"release-prepare",
201-
{
202-
files: jobs.map((job) => ({
203-
prefix,
204-
tag: job.tag,
205-
name: job.name,
206-
contentType: contentTypeFor(job.name),
207-
})),
208-
},
209-
token,
210-
);
209+
const prepare = await fcCall(ctx, "release-prepare", {
210+
files: jobs.map((job) => ({
211+
prefix,
212+
tag: job.tag,
213+
name: job.name,
214+
contentType: contentTypeFor(job.name),
215+
})),
216+
});
211217
const uploads = prepare.uploads ?? [];
212218
if (uploads.length !== jobs.length) {
213219
throw new Error(
@@ -248,14 +254,9 @@ async function uploadViaFc({ ctx, prefix, jobs, label }) {
248254

249255
// HEAD byte-size reconciliation now happens FC-side (it holds the only OSS
250256
// credentials); the runner reports local sizes as ground truth.
251-
await fcCall(
252-
ctx,
253-
"release-finalize",
254-
{
255-
files: jobs.map((job, index) => ({ key: uploads[index].key, size: statSync(job.path).size })),
256-
},
257-
token,
258-
);
257+
await fcCall(ctx, "release-finalize", {
258+
files: jobs.map((job, index) => ({ key: uploads[index].key, size: statSync(job.path).size })),
259+
});
259260
process.stdout.write(
260261
`${label} reconcile ok: ${jobs.length}/${jobs.length} object(s) verified by FC\n`,
261262
);
@@ -330,13 +331,10 @@ export async function maintainReleaseManifest({ tag, channelJsonPath = null, dry
330331
}
331332

332333
const body = JSON.parse(readFileSync(channelJsonPath, "utf-8"));
333-
const token = await fetchOidcToken(ctx.audience);
334-
const result = await fcCall(
335-
ctx,
336-
"release-finalize",
337-
{ files: [], manifest: { tag, body } },
338-
token,
339-
);
334+
const result = await fcCall(ctx, "release-finalize", {
335+
files: [],
336+
manifest: { tag, body },
337+
});
340338
if (result.manifestUpdated) {
341339
process.stdout.write(`manifest.json → latest=${result.latest ?? tag}\n`);
342340
process.stdout.write(`latest.json → ${result.latest ?? tag}\n`);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5+
import { mirrorReleaseAssetsToOss } from "./oss-direct-upload.mjs";
6+
7+
describe("OSS upload via FC", () => {
8+
afterEach(() => {
9+
vi.unstubAllEnvs();
10+
vi.unstubAllGlobals();
11+
vi.restoreAllMocks();
12+
});
13+
14+
it("refreshes the GitHub OIDC token before release-finalize", async () => {
15+
const tempDirectory = await mkdtemp(join(tmpdir(), "bailian-oss-upload-"));
16+
const assetPath = join(tempDirectory, "asset.bin");
17+
await writeFile(assetPath, "asset");
18+
19+
vi.stubEnv("FC_TRIGGER_URL", "https://fc.example");
20+
vi.stubEnv("FC_RELEASE_AUDIENCE", "release-test");
21+
vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "request-token");
22+
vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example/token");
23+
24+
let oidcRequestCount = 0;
25+
const fcAuthorizations = [];
26+
vi.stubGlobal(
27+
"fetch",
28+
vi.fn(async (input, init = {}) => {
29+
const url = String(input);
30+
if (url.startsWith("https://oidc.example/token")) {
31+
oidcRequestCount += 1;
32+
return Response.json({ value: `oidc-token-${oidcRequestCount}` });
33+
}
34+
if (url === "https://fc.example/release-prepare") {
35+
fcAuthorizations.push(init.headers.Authorization);
36+
return Response.json({
37+
success: true,
38+
uploads: [
39+
{
40+
key: "release/test/asset.bin",
41+
putUrl: "https://oss.example/asset.bin",
42+
contentType: "application/octet-stream",
43+
},
44+
],
45+
});
46+
}
47+
if (url === "https://oss.example/asset.bin") {
48+
return new Response(null, { status: 200 });
49+
}
50+
if (url === "https://fc.example/release-finalize") {
51+
fcAuthorizations.push(init.headers.Authorization);
52+
if (init.headers.Authorization !== "Bearer oidc-token-2") {
53+
return Response.json(
54+
{ success: false, error: "OIDC token 已过期", status: 403 },
55+
{ status: 403 },
56+
);
57+
}
58+
return Response.json({ success: true });
59+
}
60+
throw new Error(`Unexpected request: ${url}`);
61+
}),
62+
);
63+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
64+
65+
try {
66+
await expect(
67+
mirrorReleaseAssetsToOss({ plans: [{ tag: "test", paths: [assetPath] }] }),
68+
).resolves.toEqual({ uploaded: 1, skipped: false });
69+
expect(oidcRequestCount).toBe(2);
70+
expect(fcAuthorizations).toEqual(["Bearer oidc-token-1", "Bearer oidc-token-2"]);
71+
} finally {
72+
await rm(tempDirectory, { recursive: true, force: true });
73+
}
74+
});
75+
});

0 commit comments

Comments
 (0)