mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-06 13:36:53 +00:00
* chore(release): retire the Tideclaw alpha release track Tideclaw alpha/nightly publication is retired. Alpha remains readable as history (existing v*-alpha tags, published versions, changelog and upgrade-survivor baselines, product version ordering), but it can no longer authorize a release. Every active release boundary now rejects an alpha version or -alpha.N tag, the alpha npm dist-tag, and tideclaw/alpha/* workflow or tooling routes: preparation, Full Release Validation publication selection, npm preflight, approval receipts, core/plugin npm and ClawHub publication, native handoffs, and finalization. Channel mappings throw for alpha instead of falling through to latest. The Tideclaw branch routes, alpha dist-tag options, the alpha FRV publication route, and the alpha-only Docker runtime-assets job are removed. Beta, stable, extended-stable, and correction releases are unchanged. The release-openclaw-nightly skill is deleted and the release skills and docs no longer describe the alpha track. * test(release): drop retired alpha preparation and finalization expectations * test(release): drop remaining retired alpha references
498 lines
16 KiB
JavaScript
498 lines
16 KiB
JavaScript
import { setTimeout as delay } from "node:timers/promises";
|
|
import {
|
|
createBoundedResponseTooLargeError,
|
|
readBoundedResponseBytes,
|
|
} from "./bounded-response.mjs";
|
|
import {
|
|
classifyReleaseTrain,
|
|
compareReleaseVersions,
|
|
parseReleaseVersion,
|
|
} from "./release-version.mjs";
|
|
|
|
// npm may accept a publish before its exact version becomes readable. Publication
|
|
// callers keep their one-shot probes; only postpublish readback uses this budget.
|
|
export function npmRegistryReadbackDeadline() {
|
|
const timeoutMs = Number(process.env.OPENCLAW_NPM_READBACK_TIMEOUT_MS ?? 15 * 60_000);
|
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
throw new Error("OPENCLAW_NPM_READBACK_TIMEOUT_MS must be a positive integer <= 2147483647.");
|
|
}
|
|
return Date.now() + timeoutMs;
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} NpmPublishPlan
|
|
* @property {"stable" | "beta"} channel
|
|
* @property {"latest" | "beta" | "extended-stable"} publishTag
|
|
* @property {("latest" | "beta")[]} mirrorDistTags
|
|
*/
|
|
|
|
/**
|
|
* @typedef {"npm-readback" | "npm-mirror" | "npm-tag-repair"} PublishedNpmVersionRoute
|
|
*/
|
|
|
|
/**
|
|
* @typedef {"match" | "missing" | "lagging" | "ahead" | "incomparable" | "conflict"} NpmDistTagVersionState
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} NpmDistTagMirrorAuth
|
|
* @property {boolean} hasAuth
|
|
* @property {"node-auth-token" | "npm-token" | "none"} source
|
|
*/
|
|
|
|
/**
|
|
* @typedef {"--dry-run" | "--publish"} NpmPublishMode
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} NpmRegistryPackumentResult
|
|
* @property {number} status
|
|
* @property {boolean} ok
|
|
* @property {unknown} packument
|
|
*/
|
|
|
|
/**
|
|
* @param {Response} response
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function cancelNpmRegistryResponseBody(response) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
}
|
|
|
|
/**
|
|
* @typedef {{
|
|
* packageName: string;
|
|
* packageUrl: string;
|
|
* attempts?: number;
|
|
* timeoutMs?: number;
|
|
* deadlineMs?: number;
|
|
* fetchImpl?: (input: string, init: RequestInit) => Promise<Response>;
|
|
* sleep?: (delayMs: number) => Promise<void>;
|
|
* createSignal?: (timeoutMs: number) => AbortSignal;
|
|
* signal?: AbortSignal;
|
|
* }} NpmRegistryReadOptions
|
|
*/
|
|
|
|
export class NpmRegistryUnavailableError extends Error {}
|
|
|
|
class RetryableNpmRegistryError extends Error {
|
|
constructor(message, retryAfterMs = 0) {
|
|
super(message);
|
|
this.retryAfterMs = retryAfterMs;
|
|
}
|
|
}
|
|
|
|
function boundedReadLimit(value, fallback, maximum, label) {
|
|
const result = value ?? fallback;
|
|
if (!Number.isSafeInteger(result) || result <= 0 || result > maximum) {
|
|
throw new Error(`Invalid npm ${label}.`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function retryAfterMilliseconds(response) {
|
|
const value = response.headers?.get("retry-after")?.trim();
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
if (/^[0-9]+$/u.test(value)) {
|
|
return Number(value) * 1000;
|
|
}
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : 0;
|
|
}
|
|
|
|
/**
|
|
* Both headers and body belong to one attempt. Successful byte/identity
|
|
* comparisons stay with callers and never enter this transport retry loop.
|
|
* @template T
|
|
* @param {NpmRegistryReadOptions} params
|
|
* @param {{ label: string; headers?: Record<string, string>; redirect?: RequestRedirect;
|
|
* read: (response: Response, signal: AbortSignal) => Promise<T> }} reader
|
|
* @returns {Promise<{ status: number; ok: boolean; body: T | null }>}
|
|
*/
|
|
async function fetchNpmRegistryWithRetry(params, reader) {
|
|
const attempts = boundedReadLimit(params.attempts, 3, 5, "read attempts");
|
|
const timeoutMs = boundedReadLimit(params.timeoutMs, 20_000, 60_000, "read timeout");
|
|
const deadlineMs = Math.min(
|
|
boundedReadLimit(
|
|
params.deadlineMs,
|
|
Date.now() + 180_000,
|
|
Number.MAX_SAFE_INTEGER,
|
|
"read deadline",
|
|
),
|
|
Date.now() + 180_000,
|
|
);
|
|
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
|
|
const sleep = params.sleep ?? delay;
|
|
const createSignal = params.createSignal ?? ((delayMs) => AbortSignal.timeout(delayMs));
|
|
let lastError;
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
params.signal?.throwIfAborted();
|
|
let response;
|
|
const remainingMs = deadlineMs - Date.now();
|
|
if (remainingMs <= 0) {
|
|
throw new NpmRegistryUnavailableError(`${reader.label} deadline exceeded.`);
|
|
}
|
|
try {
|
|
const attemptSignal = createSignal(Math.min(timeoutMs, remainingMs));
|
|
const signal = params.signal
|
|
? AbortSignal.any([params.signal, attemptSignal])
|
|
: attemptSignal;
|
|
response = await fetchImpl(params.packageUrl, {
|
|
headers: reader.headers,
|
|
redirect: reader.redirect,
|
|
signal,
|
|
});
|
|
if (Date.now() >= deadlineMs) {
|
|
await cancelNpmRegistryResponseBody(response);
|
|
throw new NpmRegistryUnavailableError(`${reader.label} deadline exceeded.`);
|
|
}
|
|
if ([408, 429, 500, 502, 503, 504].includes(response.status)) {
|
|
await cancelNpmRegistryResponseBody(response);
|
|
throw new RetryableNpmRegistryError(
|
|
`HTTP ${response.status}`,
|
|
retryAfterMilliseconds(response),
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
await cancelNpmRegistryResponseBody(response);
|
|
return { status: response.status, ok: false, body: null };
|
|
}
|
|
const body = await reader.read(response, signal);
|
|
params.signal?.throwIfAborted();
|
|
if (Date.now() >= deadlineMs) {
|
|
throw new NpmRegistryUnavailableError(`${reader.label} deadline exceeded.`);
|
|
}
|
|
return { status: response.status, ok: true, body };
|
|
} catch (error) {
|
|
if (response?.ok) {
|
|
await cancelNpmRegistryResponseBody(response);
|
|
}
|
|
// An owning collection's cancellation is not a transient attempt timeout.
|
|
params.signal?.throwIfAborted();
|
|
if (
|
|
!(error instanceof RetryableNpmRegistryError) &&
|
|
!["AbortError", "TimeoutError", "TypeError"].includes(error?.name) &&
|
|
!["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "UND_ERR_SOCKET"].includes(error?.code)
|
|
) {
|
|
throw error;
|
|
}
|
|
lastError = error;
|
|
}
|
|
if (attempt < attempts) {
|
|
const retryDelayMs = Math.max(attempt * 1000, lastError?.retryAfterMs ?? 0);
|
|
if (retryDelayMs >= deadlineMs - Date.now()) {
|
|
throw new NpmRegistryUnavailableError(
|
|
`${reader.label} deadline would be exceeded before the permitted retry.`,
|
|
{
|
|
cause: lastError,
|
|
},
|
|
);
|
|
}
|
|
if (params.signal && !params.sleep) {
|
|
await delay(retryDelayMs, undefined, { signal: params.signal });
|
|
} else {
|
|
await sleep(retryDelayMs);
|
|
}
|
|
params.signal?.throwIfAborted();
|
|
}
|
|
}
|
|
|
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
throw new NpmRegistryUnavailableError(
|
|
`${reader.label} did not return a stable response: ${message}.`,
|
|
{
|
|
cause: lastError,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {NpmRegistryReadOptions & { maxBytes?: number; redirect?: RequestRedirect }} params
|
|
* @returns {Promise<NpmRegistryPackumentResult>}
|
|
*/
|
|
export async function fetchNpmRegistryPackumentWithRetry(params) {
|
|
const maxBytes =
|
|
params.maxBytes === undefined
|
|
? undefined
|
|
: boundedReadLimit(params.maxBytes, undefined, 16 * 1024 * 1024, "packument byte limit");
|
|
const result = await fetchNpmRegistryWithRetry(params, {
|
|
label: `${params.packageName}: npm publication-route probe`,
|
|
headers: { accept: "application/vnd.npm.install-v1+json" },
|
|
redirect: params.redirect,
|
|
read: async (response, signal) => {
|
|
const body =
|
|
maxBytes === undefined
|
|
? await response.text()
|
|
: (
|
|
await readBoundedResponseBytes(
|
|
response,
|
|
`${params.packageName}: npm packument`,
|
|
maxBytes,
|
|
{
|
|
signal,
|
|
createTooLargeError: createBoundedResponseTooLargeError,
|
|
},
|
|
)
|
|
).toString("utf8");
|
|
try {
|
|
return JSON.parse(body);
|
|
} catch (error) {
|
|
throw new RetryableNpmRegistryError(
|
|
`${params.packageName}: npm publication-route probe returned invalid JSON: ${error instanceof Error ? error.message : String(error)}.`,
|
|
);
|
|
}
|
|
},
|
|
});
|
|
return { status: result.status, ok: result.ok, packument: result.body };
|
|
}
|
|
|
|
/** @param {NpmRegistryReadOptions & { maxBytes: number }} params */
|
|
export async function fetchNpmRegistryTarballWithRetry(params) {
|
|
const maxBytes = boundedReadLimit(
|
|
params.maxBytes,
|
|
undefined,
|
|
256 * 1024 * 1024,
|
|
"tarball byte limit",
|
|
);
|
|
const label = `${params.packageName}: npm tarball readback`;
|
|
const result = await fetchNpmRegistryWithRetry(
|
|
{ ...params, timeoutMs: params.timeoutMs ?? 60_000 },
|
|
{
|
|
label,
|
|
// Redirects cannot change the registry owner selected by the verified packument.
|
|
redirect: "manual",
|
|
read: (response, signal) =>
|
|
readBoundedResponseBytes(response, label, maxBytes, {
|
|
signal,
|
|
createTooLargeError: createBoundedResponseTooLargeError,
|
|
}),
|
|
},
|
|
);
|
|
if (result.status === 404) {
|
|
throw new NpmRegistryUnavailableError(`${label} returned HTTP 404.`);
|
|
}
|
|
if (!result.ok || result.body === null) {
|
|
throw new Error(`${label} returned HTTP ${result.status}.`);
|
|
}
|
|
return result.body;
|
|
}
|
|
|
|
/**
|
|
* @param {string} version
|
|
* @param {string | null} [currentBetaVersion]
|
|
* @param {string | null} [publishTagOverride]
|
|
* @returns {NpmPublishPlan}
|
|
*/
|
|
export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOverride) {
|
|
const parsedVersion = parseReleaseVersion(version);
|
|
if (parsedVersion === null) {
|
|
throw new Error(`Unsupported release version "${version}".`);
|
|
}
|
|
if (parsedVersion.channel === "alpha" || publishTagOverride?.trim() === "alpha") {
|
|
throw new Error("Alpha releases are retired; use a beta prerelease instead.");
|
|
}
|
|
const releaseTrain = classifyReleaseTrain(parsedVersion);
|
|
|
|
const normalizedOverride = publishTagOverride?.trim();
|
|
if (normalizedOverride && normalizedOverride !== "extended-stable") {
|
|
throw new Error(
|
|
`Unsupported npm publish tag override "${normalizedOverride}". Expected "extended-stable".`,
|
|
);
|
|
}
|
|
if (normalizedOverride === "extended-stable") {
|
|
if (releaseTrain !== "extended-stable") {
|
|
throw new Error(
|
|
`Extended-stable npm publication requires a final YYYY.M.PATCH version with PATCH >= 33; found "${version}".`,
|
|
);
|
|
}
|
|
return {
|
|
channel: "stable",
|
|
publishTag: "extended-stable",
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
|
|
if (parsedVersion.channel === "beta") {
|
|
return {
|
|
channel: "beta",
|
|
publishTag: "beta",
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
|
|
const normalizedCurrentBeta = currentBetaVersion?.trim();
|
|
if (normalizedCurrentBeta) {
|
|
const betaVsStable = compareReleaseVersions(normalizedCurrentBeta, version);
|
|
if (betaVsStable !== null && betaVsStable > 0) {
|
|
return {
|
|
channel: "stable",
|
|
publishTag: "latest",
|
|
mirrorDistTags: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
channel: "stable",
|
|
publishTag: "latest",
|
|
mirrorDistTags: ["beta"],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} NpmVersionPublicationDecision
|
|
* @property {PublishedNpmVersionRoute | null} route Registry route for a published version; null when publication is still planned.
|
|
* @property {string | null} supersededBy The newer version the primary dist-tag keeps when the published target is skipped.
|
|
*/
|
|
|
|
/**
|
|
* @param {{
|
|
* packageVersion: string;
|
|
* publishPlan: NpmPublishPlan;
|
|
* distTags: Record<string, unknown>;
|
|
* }} params
|
|
* @returns {PublishedNpmVersionRoute}
|
|
*/
|
|
export function resolvePublishedNpmVersionRoute(params) {
|
|
return /** @type {PublishedNpmVersionRoute} */ (
|
|
resolveNpmVersionPublicationDecision({ ...params, published: true }).route
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A published target whose primary dist-tag already points past it was
|
|
* superseded by a later release: nothing is published and no selector moves.
|
|
* An unpublished target behind an ahead selector would publish inconsistently.
|
|
* @param {{
|
|
* packageVersion: string;
|
|
* publishPlan: NpmPublishPlan;
|
|
* distTags: Record<string, unknown>;
|
|
* published: boolean;
|
|
* }} params
|
|
* @returns {NpmVersionPublicationDecision}
|
|
*/
|
|
export function resolveNpmVersionPublicationDecision(params) {
|
|
const primaryVersion = params.distTags[params.publishPlan.publishTag];
|
|
const primaryState = classifyNpmDistTagVersion(primaryVersion, params.packageVersion);
|
|
if (primaryState === "ahead" && params.published) {
|
|
return { route: "npm-readback", supersededBy: /** @type {string} */ (primaryVersion) };
|
|
}
|
|
if (!params.published) {
|
|
// Placeholder selectors (0.0.0) are expected before a first real publication.
|
|
if (primaryState === "ahead") {
|
|
throwUnsafeNpmDistTag(
|
|
params.publishPlan.publishTag,
|
|
primaryVersion,
|
|
params.packageVersion,
|
|
primaryState,
|
|
);
|
|
}
|
|
return { route: null, supersededBy: null };
|
|
}
|
|
const needsPrimaryRepair = primaryState === "missing" || primaryState === "lagging";
|
|
if (!needsPrimaryRepair && primaryState !== "match") {
|
|
throwUnsafeNpmDistTag(
|
|
params.publishPlan.publishTag,
|
|
primaryVersion,
|
|
params.packageVersion,
|
|
primaryState,
|
|
);
|
|
}
|
|
|
|
let needsMirrorRepair = false;
|
|
for (const distTag of params.publishPlan.mirrorDistTags) {
|
|
const mirrorState = classifyNpmDistTagVersion(params.distTags[distTag], params.packageVersion);
|
|
if (mirrorState === "missing" || mirrorState === "lagging") {
|
|
needsMirrorRepair = true;
|
|
continue;
|
|
}
|
|
if (mirrorState !== "match") {
|
|
throwUnsafeNpmDistTag(distTag, params.distTags[distTag], params.packageVersion, mirrorState);
|
|
}
|
|
}
|
|
if (needsPrimaryRepair) {
|
|
return { route: "npm-tag-repair", supersededBy: null };
|
|
}
|
|
return { route: needsMirrorRepair ? "npm-mirror" : "npm-readback", supersededBy: null };
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} currentVersion
|
|
* @param {string} targetVersion
|
|
* @returns {NpmDistTagVersionState}
|
|
*/
|
|
export function classifyNpmDistTagVersion(currentVersion, targetVersion) {
|
|
if (currentVersion === undefined) {
|
|
return "missing";
|
|
}
|
|
if (typeof currentVersion !== "string") {
|
|
return "incomparable";
|
|
}
|
|
if (currentVersion === targetVersion) {
|
|
return "match";
|
|
}
|
|
const comparison = compareReleaseVersions(currentVersion, targetVersion);
|
|
if (comparison === null) {
|
|
return "incomparable";
|
|
}
|
|
if (comparison < 0) {
|
|
return "lagging";
|
|
}
|
|
if (comparison > 0) {
|
|
return "ahead";
|
|
}
|
|
return "conflict";
|
|
}
|
|
|
|
/**
|
|
* @param {string} distTag
|
|
* @param {unknown} currentVersion
|
|
* @param {string} targetVersion
|
|
* @param {NpmDistTagVersionState} state
|
|
* @returns {never}
|
|
*/
|
|
function throwUnsafeNpmDistTag(distTag, currentVersion, targetVersion, state) {
|
|
throw new Error(
|
|
`npm dist-tag "${distTag}" points to ${JSON.stringify(currentVersion)} and cannot be safely moved to "${targetVersion}" (${state}).`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* nodeAuthToken?: string | null | undefined;
|
|
* npmToken?: string | null | undefined;
|
|
* }} [params]
|
|
* @returns {NpmDistTagMirrorAuth}
|
|
*/
|
|
export function resolveNpmDistTagMirrorAuth(params = {}) {
|
|
const nodeAuthToken = params.nodeAuthToken?.trim();
|
|
if (nodeAuthToken) {
|
|
return { hasAuth: true, source: "node-auth-token" };
|
|
}
|
|
|
|
const npmToken = params.npmToken?.trim();
|
|
if (npmToken) {
|
|
return { hasAuth: true, source: "npm-token" };
|
|
}
|
|
|
|
return { hasAuth: false, source: "none" };
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* mode: NpmPublishMode;
|
|
* mirrorDistTags: string[] | readonly string[];
|
|
* hasAuth: boolean;
|
|
* }} params
|
|
* @returns {boolean}
|
|
*/
|
|
export function shouldRequireNpmDistTagMirrorAuth(params) {
|
|
return (
|
|
params.mode === "--publish" &&
|
|
params.mirrorDistTags.some((distTag) => distTag.trim().length > 0) &&
|
|
!params.hasAuth
|
|
);
|
|
}
|