mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(release): preserve 2026.8.33 update and validation safety (#153362)
* fix: fence claimless ackDelivery against a live platform-send claim (#153083) The claimless branch of ackDeliveryInDatabase read the row and settled it without checking for a live producer/platform-send claim, so a caller with no owner of its own could delete another worker's in-flight delivery. Route it through the same fenced transition the owned path already uses, with the absent owner treated as null and a missing row still a no-op. (cherry picked from commit165099e7e9) * fix(discord): stop /new and /reset from skipping guild guards (#152991) Discord /new and /reset skipped every configured guild guard in every ordinary guild channel. A channel configured enabled:false still answered both commands, while /status in the same channel was correctly refused. canBypassConfiguredAcpGuildGuards ended in a four-way OR whose last clause was routeState.configuredRoute != null. resolveConfiguredBindingRoute has three return sites and all three are object literals, so configuredRoute is never null for a non-thread guild channel and the bypass fired always. That also made the correct check one line above, configuredBinding != null, dead code. Drop the vacuous clause. configuredBinding is non-null exactly when a configured binding matched the conversation, so the intended ACP recovery bypass still fires for ACP-bound channels while ordinary channels get the enabled, allowed, group-policy and guild-command-authorization guards back. Adds the first coverage for the native-command kill switch path. (cherry picked from commit0be25b288c) * test(ui): accept equivalent round corner serialization Accept round and superellipse(1) as equivalent circular CSSOM values. Keep radius, non-circular shape, excluded-surface, and root-token assertions unchanged. This is a release verification repair only. (cherry picked from commit46dc9d8dad) * fix(release): keep frozen plugin harness pins advisory * test(release): stabilize frozen validation gates * fix(release): preserve strict ClawHub dependency checks * chore(release): keep freshness collector private --------- Co-authored-by: Jialong(Bruce) Li <chelsealong@126.com> Co-authored-by: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
aea01a2522
commit
ea06b14d64
12 changed files with 366 additions and 58 deletions
1
.github/workflows/plugin-npm-release.yml
vendored
1
.github/workflows/plugin-npm-release.yml
vendored
|
|
@ -379,6 +379,7 @@ jobs:
|
|||
fi
|
||||
|
||||
cat .local/plugin-npm-release-plan.json
|
||||
jq -r '.warnings[]? | "- Warning: \(.)"' .local/plugin-npm-release-plan.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
|
||||
selection_count="$(jq -r '.all | length' .local/plugin-npm-release-plan.json)"
|
||||
|
|
|
|||
|
|
@ -3148,7 +3148,7 @@ src/infra/outbound/deliver-queue-state.ts 1
|
|||
src/infra/outbound/delivery-queue-media-spool.ts 2
|
||||
src/infra/outbound/delivery-queue-media-staging.ts 2
|
||||
src/infra/outbound/delivery-queue-preparation.ts 1
|
||||
src/infra/outbound/delivery-queue-storage.ts 8
|
||||
src/infra/outbound/delivery-queue-storage.ts 7
|
||||
src/infra/outbound/envelope.ts 2
|
||||
src/infra/outbound/format.ts 1
|
||||
src/infra/outbound/message-account-selection.ts 2
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
|
|||
- That workflow runs `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_CACHE_TEST=1 pnpm test:live:cache` using both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` workflow secrets.
|
||||
- npm release preflight no longer waits on the separate release checks lane.
|
||||
- Before tagging a release candidate locally, run `RELEASE_TAG=vYYYY.M.PATCH-beta.N pnpm release:fast-pretag-check`. The helper runs the fast release guardrails, plugin npm/ClawHub release checks, build, UI build, and `release:openclaw:npm:check` in the order that catches common approval-blocking mistakes before the GitHub publish workflow starts.
|
||||
- Plugin `openclaw.release.requireLatestDependencies` declarations remain release metadata, but npm `latest` drift is advisory. Checks warn with the plugin, dependency, pinned version, and current latest version; a failed latest lookup also warns and does not establish that the pin is unusable. Full Release Validation's Codex lanes validate the `@openclaw/codex` harness pin. Keep that frozen, tested pin when upstream publishes a newer version. Missing or malformed required runtime dependency metadata, package/install failures, and failed required validation lanes still block release.
|
||||
- Run `RELEASE_TAG=vYYYY.M.PATCH node --import tsx scripts/openclaw-npm-release-check.ts` (or the matching prerelease/correction tag) before approval.
|
||||
- After npm publish, run `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.PATCH` (or the matching beta/correction version) to verify the published registry install path in a fresh temp prefix.
|
||||
- After a beta publish, run `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@YYYY.M.PATCH-beta.N OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=maintainer pnpm test:docker:npm-telegram-live` with `OPENCLAW_QA_CONVEX_SITE_URL` and `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER` set. This verifies installed-package onboarding, Telegram setup, and real Telegram E2E against the published npm package using the shared Test Server userbot pool. CI uses the `ci` role and `OPENCLAW_QA_CONVEX_SECRET_CI` instead.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createTestRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { discordPlugin } from "../channel.js";
|
||||
import type { CommandInteraction } from "../internal/discord.js";
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
import { createMockCommandInteraction } from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.manager.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const userId = "100000000000000003";
|
||||
const channelId = "100000000000000001";
|
||||
const guildId = "100000000000000002";
|
||||
const otherChannelId = "100000000000000005";
|
||||
const sessionId = "existing-channel-session";
|
||||
|
||||
afterEach(async () => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
await Promise.all(
|
||||
directories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function runNativeCommand(params: {
|
||||
commandName: string;
|
||||
guildChannels: Record<string, { enabled?: boolean }>;
|
||||
configuredBinding?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "discord-guild-guards-"));
|
||||
directories.push(home);
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const sessionKey = `agent:main:discord:channel:${channelId}`;
|
||||
const scope = { agentId: "main", storePath, sessionKey };
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { workspace: home } },
|
||||
session: { store: storePath },
|
||||
commands: { allowFrom: { discord: [`user:${userId}`] } },
|
||||
...(params.configuredBinding
|
||||
? {
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "main",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: channelId },
|
||||
},
|
||||
acp: { backend: "acpx" },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
channels: {
|
||||
discord: {
|
||||
commands: { native: true },
|
||||
guilds: { [guildId]: { channels: params.guildChannels } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]),
|
||||
);
|
||||
await upsertSessionEntry({
|
||||
...scope,
|
||||
entry: {
|
||||
sessionId,
|
||||
lifecycleRevision: "before-reset",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 100,
|
||||
},
|
||||
});
|
||||
setRuntimeConfigSnapshot(cfg);
|
||||
const interaction = createMockCommandInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
guildId,
|
||||
userId,
|
||||
interactionId: params.label,
|
||||
});
|
||||
const command = createDiscordNativeCommand({
|
||||
command: { name: params.commandName, description: "Guild guard probe.", acceptsArgs: true },
|
||||
cfg,
|
||||
discordConfig: cfg.channels!.discord!,
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
await command.run(interaction as unknown as CommandInteraction);
|
||||
return {
|
||||
entry: getSessionEntry(scope),
|
||||
replies: [...interaction.reply.mock.calls, ...interaction.followUp.mock.calls].map(
|
||||
([payload]) => payload?.content,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("discord native command guild guards", () => {
|
||||
it.each(["reset", "new"] as const)(
|
||||
"refuses /%s in a disabled guild channel with no configured binding",
|
||||
async (commandName) => {
|
||||
const result = await runNativeCommand({
|
||||
commandName,
|
||||
guildChannels: { [channelId]: { enabled: false } },
|
||||
label: `disabled-${commandName}`,
|
||||
});
|
||||
expect(result.replies).toEqual(["This channel is disabled."]);
|
||||
expect(result.entry?.lifecycleRevision).toBe("before-reset");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["reset", "new"] as const)(
|
||||
"refuses /%s in a not-allowed guild channel with no configured binding",
|
||||
async (commandName) => {
|
||||
const result = await runNativeCommand({
|
||||
commandName,
|
||||
guildChannels: { [otherChannelId]: { enabled: true } },
|
||||
label: `notallowed-${commandName}`,
|
||||
});
|
||||
expect(result.replies).toEqual(["This channel is not allowed."]);
|
||||
expect(result.entry?.lifecycleRevision).toBe("before-reset");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses /status in a disabled guild channel", async () => {
|
||||
const result = await runNativeCommand({
|
||||
commandName: "status",
|
||||
guildChannels: { [channelId]: { enabled: false } },
|
||||
label: "disabled-status",
|
||||
});
|
||||
expect(result.replies).toEqual(["This channel is disabled."]);
|
||||
});
|
||||
|
||||
it.each(["reset", "new"] as const)(
|
||||
"still bypasses the guards for /%s when a configured binding owns the channel",
|
||||
async (commandName) => {
|
||||
const result = await runNativeCommand({
|
||||
commandName,
|
||||
guildChannels: { [channelId]: { enabled: false } },
|
||||
configuredBinding: true,
|
||||
label: `bound-${commandName}`,
|
||||
});
|
||||
expect(result.replies).not.toContain("This channel is disabled.");
|
||||
expect(result.replies).not.toContain("This channel is not allowed.");
|
||||
expect(result.replies.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["reset", "new"] as const)(
|
||||
"passes guild guards for /%s in an enabled channel with no configured binding",
|
||||
async (commandName) => {
|
||||
const result = await runNativeCommand({
|
||||
commandName,
|
||||
guildChannels: { [channelId]: { enabled: true } },
|
||||
label: `enabled-${commandName}`,
|
||||
});
|
||||
expect(result.replies).not.toContain("This channel is disabled.");
|
||||
expect(result.replies).not.toContain("This channel is not allowed.");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -368,8 +368,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||
return (
|
||||
routeState.effectiveRoute.matchedBy === "binding.channel" ||
|
||||
routeState.boundSessionKey != null ||
|
||||
routeState.configuredBinding != null ||
|
||||
routeState.configuredRoute != null
|
||||
routeState.configuredBinding != null
|
||||
);
|
||||
};
|
||||
if (channelConfig?.enabled === false && !(await canBypassConfiguredAcpGuildGuards())) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type PluginReleasePlanItem = PublishablePluginPackage & {
|
|||
|
||||
type PluginReleasePlan = {
|
||||
all: PluginReleasePlanItem[];
|
||||
warnings: string[];
|
||||
candidates: PluginReleasePlanItem[];
|
||||
skippedPublished: PluginReleasePlanItem[];
|
||||
};
|
||||
|
|
@ -420,14 +421,13 @@ function resolveNpmLatestVersion(packageName: string): string {
|
|||
return parsed.trim();
|
||||
}
|
||||
|
||||
export function collectPluginReleaseDependencyFreshnessErrors(
|
||||
function collectPluginReleaseDependencyFreshnessMessages(
|
||||
plugins: readonly PublishablePluginPackage[],
|
||||
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
|
||||
resolveLatestVersion: NpmLatestVersionResolver,
|
||||
policy: "advisory" | "strict",
|
||||
): string[] {
|
||||
// Only plugin-owned opt-ins use this strict gate. It prevents release branches
|
||||
// from silently carrying old executable pins while leaving normal dependencies alone.
|
||||
const latestVersions = new Map<string, string>();
|
||||
const errors: string[] = [];
|
||||
const messages: string[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (const dependency of plugin.requiredLatestDependencies ?? []) {
|
||||
|
|
@ -437,21 +437,41 @@ export function collectPluginReleaseDependencyFreshnessErrors(
|
|||
latestVersion = resolveLatestVersion(dependency.packageName);
|
||||
latestVersions.set(dependency.packageName, latestVersion);
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
messages.push(
|
||||
policy === "advisory"
|
||||
? `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName} (pinned "${dependency.version}"); freshness is advisory: ${error instanceof Error ? error.message : String(error)}`
|
||||
: `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (dependency.version !== latestVersion) {
|
||||
errors.push(
|
||||
`${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`,
|
||||
messages.push(
|
||||
policy === "advisory"
|
||||
? `${plugin.packageName}@${plugin.version}: ${dependency.packageName} pinned "${dependency.version}", npm latest is "${latestVersion}". Freshness is advisory; retain the release-validated pin.`
|
||||
: `${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function collectPluginReleaseDependencyFreshnessWarnings(
|
||||
plugins: readonly PublishablePluginPackage[],
|
||||
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
|
||||
): string[] {
|
||||
// Release validation owns pin compatibility. A moving npm dist-tag must not
|
||||
// invalidate a frozen, tested candidate, including when the lookup is unavailable.
|
||||
return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "advisory");
|
||||
}
|
||||
|
||||
function collectPluginReleaseDependencyFreshnessErrors(
|
||||
plugins: readonly PublishablePluginPackage[],
|
||||
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
|
||||
): string[] {
|
||||
return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "strict");
|
||||
}
|
||||
|
||||
export function assertPluginReleaseDependencyFreshness(
|
||||
|
|
@ -470,6 +490,18 @@ export function assertPluginReleaseDependencyFreshness(
|
|||
);
|
||||
}
|
||||
|
||||
export function warnPluginReleaseDependencyFreshness(
|
||||
plugins: readonly PublishablePluginPackage[],
|
||||
label: string,
|
||||
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
|
||||
): string[] {
|
||||
const warnings = collectPluginReleaseDependencyFreshnessWarnings(plugins, resolveLatestVersion);
|
||||
for (const warning of warnings) {
|
||||
console.warn(`${label}: warning: ${warning}`);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function isPluginVersionPublished(packageName: string, version: string): boolean {
|
||||
try {
|
||||
runNpmView([`${packageName}@${version}`, "version"]);
|
||||
|
|
@ -527,7 +559,10 @@ export function collectPluginReleasePlan(params?: {
|
|||
if (explicitPublishSelection) {
|
||||
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin NPM release plan");
|
||||
}
|
||||
assertPluginReleaseDependencyFreshness(selectedPublishable, "Plugin NPM release plan");
|
||||
const warnings = warnPluginReleaseDependencyFreshness(
|
||||
selectedPublishable,
|
||||
"Plugin NPM release plan",
|
||||
);
|
||||
|
||||
const all = selectedPublishable.map((plugin) =>
|
||||
Object.assign({}, plugin, {
|
||||
|
|
@ -537,6 +572,7 @@ export function collectPluginReleasePlan(params?: {
|
|||
|
||||
return {
|
||||
all,
|
||||
warnings,
|
||||
candidates: all.filter((plugin) => !plugin.alreadyPublished),
|
||||
skippedPublished: all.filter((plugin) => plugin.alreadyPublished),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export function transitionOwnedDeliveryQueueEntry(
|
|||
id: string;
|
||||
stateDir?: string;
|
||||
platformSendAttemptId: string | null;
|
||||
/** A caller with no owner of its own treats an already-settled row as a no-op. */
|
||||
allowMissingEntry?: boolean;
|
||||
},
|
||||
transition: (entry: DeliveryQueueEntryState) => void,
|
||||
): boolean {
|
||||
|
|
@ -49,7 +51,7 @@ export function transitionOwnedDeliveryQueueEntry(
|
|||
() => {
|
||||
const entry = loadDeliveryQueueEntry(params.queueName, params.id, params.stateDir);
|
||||
if (!entry) {
|
||||
return false;
|
||||
return params.allowMissingEntry === true;
|
||||
}
|
||||
if (
|
||||
params.platformSendAttemptId === null
|
||||
|
|
|
|||
|
|
@ -298,23 +298,23 @@ export async function ackDelivery(
|
|||
deleteDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir);
|
||||
}
|
||||
};
|
||||
if (options && "expectedPlatformSendAttemptId" in options) {
|
||||
const settled = transitionOwnedDeliveryQueueEntry(
|
||||
{
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
id,
|
||||
stateDir,
|
||||
platformSendAttemptId: options.expectedPlatformSendAttemptId ?? null,
|
||||
},
|
||||
(entry) => settle(entry as QueuedDelivery),
|
||||
);
|
||||
if (!settled) {
|
||||
throw lostPlatformClaim(id);
|
||||
}
|
||||
} else {
|
||||
settle(
|
||||
loadDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir) as QueuedDelivery | null,
|
||||
);
|
||||
// A claimless caller has no owner to assert, so an unclaimed row settles and an
|
||||
// already-missing row is a no-op; either way it must never touch a live claim.
|
||||
const ownsPlatformAttempt = options && "expectedPlatformSendAttemptId" in options;
|
||||
const settled = transitionOwnedDeliveryQueueEntry(
|
||||
{
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
id,
|
||||
stateDir,
|
||||
platformSendAttemptId: ownsPlatformAttempt
|
||||
? (options.expectedPlatformSendAttemptId ?? null)
|
||||
: null,
|
||||
allowMissingEntry: !ownsPlatformAttempt,
|
||||
},
|
||||
(entry) => settle(entry as QueuedDelivery),
|
||||
);
|
||||
if (!settled) {
|
||||
throw lostPlatformClaim(id);
|
||||
}
|
||||
if (!options?.retainSpoolArtifacts) {
|
||||
await releaseSpoolArtifacts(spoolPaths, stateDir);
|
||||
|
|
|
|||
|
|
@ -420,6 +420,27 @@ describe("delivery-queue storage", () => {
|
|||
await expect(ackDelivery("nonexistent-id", tmpDir())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("claimless ack rejects a live-claimed row instead of deleting it", async () => {
|
||||
const stateDir = tmpDir();
|
||||
const id = await enqueueTextDelivery({
|
||||
channel: "directchat",
|
||||
to: "+1",
|
||||
payloads: [{ text: "claimless-ack-guard" }],
|
||||
});
|
||||
const attemptId = await claimDeliveryPlatformSendAttempt(id, stateDir);
|
||||
if (!attemptId) {
|
||||
throw new Error("test invariant: the unclaimed row must accept a platform claim");
|
||||
}
|
||||
|
||||
await expect(ackDelivery(id, stateDir)).rejects.toThrow(
|
||||
`Delivery platform claim was lost: ${id}`,
|
||||
);
|
||||
|
||||
const pending = await loadPendingDelivery(id, stateDir);
|
||||
expect(pending).toMatchObject({ id, producerClaimId: attemptId });
|
||||
expect(readStatus(id)).toBe("pending");
|
||||
});
|
||||
|
||||
it("removes acked entries from pending recovery", async () => {
|
||||
const id = await enqueueTextDelivery({
|
||||
channel: "directchat",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { collectClawHubPublishablePluginPackages } from "../scripts/lib/plugin-clawhub-release.ts";
|
||||
import {
|
||||
collectChangedExtensionIdsFromPaths,
|
||||
collectPluginReleaseDependencyFreshnessErrors,
|
||||
collectPluginReleaseDependencyFreshnessWarnings,
|
||||
collectPluginNpmGitRangeSelection,
|
||||
collectPluginReleasePlan,
|
||||
collectPluginReleaseVersionFloorErrors,
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
parsePluginReleaseSelectionMode,
|
||||
resolveChangedPublishablePluginPackages,
|
||||
resolveSelectedPublishablePluginPackages,
|
||||
warnPluginReleaseDependencyFreshness,
|
||||
type PublishablePluginPackage,
|
||||
} from "../scripts/lib/plugin-npm-release.ts";
|
||||
import { writePublishablePluginFixture } from "./helpers/publishable-plugin-fixture.js";
|
||||
|
|
@ -47,6 +48,7 @@ const tempDirs: string[] = [];
|
|||
|
||||
afterEach(() => {
|
||||
childProcessMock.execFileSyncOverride = undefined;
|
||||
vi.restoreAllMocks();
|
||||
cleanupTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
|
|
@ -416,7 +418,7 @@ describe("collectPluginReleaseVersionFloorErrors", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("collectPluginReleaseDependencyFreshnessErrors", () => {
|
||||
describe("collectPluginReleaseDependencyFreshnessWarnings", () => {
|
||||
const plugin: PublishablePluginPackage = {
|
||||
extensionId: "codex",
|
||||
packageDir: "extensions/codex",
|
||||
|
|
@ -432,15 +434,15 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
|
|||
],
|
||||
};
|
||||
|
||||
it("rejects release dependencies older than the npm latest dist-tag", () => {
|
||||
expect(collectPluginReleaseDependencyFreshnessErrors([plugin], () => "0.142.5")).toEqual([
|
||||
'@openclaw/codex@2026.6.11: @openai/codex must match npm latest for release; found "0.139.0", latest is "0.142.5".',
|
||||
it("reports release dependencies older than the npm latest dist-tag", () => {
|
||||
expect(collectPluginReleaseDependencyFreshnessWarnings([plugin], () => "0.142.5")).toEqual([
|
||||
'@openclaw/codex@2026.6.11: @openai/codex pinned "0.139.0", npm latest is "0.142.5". Freshness is advisory; retain the release-validated pin.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts release dependencies matching the npm latest dist-tag", () => {
|
||||
expect(
|
||||
collectPluginReleaseDependencyFreshnessErrors(
|
||||
collectPluginReleaseDependencyFreshnessWarnings(
|
||||
[
|
||||
{
|
||||
...plugin,
|
||||
|
|
@ -457,17 +459,17 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
|
|||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed when npm latest cannot be resolved", () => {
|
||||
it("reports unavailable npm latest as advisory", () => {
|
||||
expect(
|
||||
collectPluginReleaseDependencyFreshnessErrors([plugin], () => {
|
||||
collectPluginReleaseDependencyFreshnessWarnings([plugin], () => {
|
||||
throw new Error("registry unavailable");
|
||||
}),
|
||||
).toEqual([
|
||||
"@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: registry unavailable",
|
||||
'@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: registry unavailable',
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when the npm latest lookup times out", () => {
|
||||
it("reports npm latest lookup timeouts as advisory", () => {
|
||||
childProcessMock.execFileSyncOverride = ((
|
||||
command: string,
|
||||
args?: readonly string[],
|
||||
|
|
@ -489,13 +491,54 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
|
|||
throw Object.assign(new Error("spawnSync npm ETIMEDOUT"), { code: "ETIMEDOUT" });
|
||||
}) as unknown as ExecFileSync;
|
||||
|
||||
expect(collectPluginReleaseDependencyFreshnessErrors([plugin])).toEqual([
|
||||
"@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: npm view timed out after 60000ms.",
|
||||
expect(collectPluginReleaseDependencyFreshnessWarnings([plugin])).toEqual([
|
||||
'@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: npm view timed out after 60000ms.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("logs advisory warnings without blocking the caller", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const warnings = warnPluginReleaseDependencyFreshness(
|
||||
[plugin],
|
||||
"release check",
|
||||
() => "0.142.5",
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(`release check: warning: ${warnings[0]}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPluginReleasePlan", () => {
|
||||
it("keeps a publish candidate when npm latest moved after validation", () => {
|
||||
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
|
||||
writePublishablePluginFixture(repoDir, {
|
||||
version: "2026.8.33",
|
||||
publishTo: "npm",
|
||||
dependency: { packageName: "demo-runtime", version: "1.2.3", requireLatest: true },
|
||||
});
|
||||
childProcessMock.execFileSyncOverride = ((command: string, args?: readonly string[]) => {
|
||||
expect(command).toBe("npm");
|
||||
if (args?.[2] === "dist-tags.latest") {
|
||||
return JSON.stringify("1.2.4");
|
||||
}
|
||||
throw new Error("version not published");
|
||||
}) as unknown as ExecFileSync;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const plan = collectPluginReleasePlan({ rootDir: repoDir });
|
||||
|
||||
expect(plan.candidates.map((candidate) => candidate.packageName)).toEqual([
|
||||
"@openclaw/demo-plugin",
|
||||
]);
|
||||
expect(plan.warnings).toEqual([
|
||||
'@openclaw/demo-plugin@2026.8.33: demo-runtime pinned "1.2.3", npm latest is "1.2.4". Freshness is advisory; retain the release-validated pin.',
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
`Plugin NPM release plan: warning: ${plan.warnings[0]}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the published-version lookup times out", () => {
|
||||
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
|
||||
writePublishablePluginFixture(repoDir, {
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ process.exitCode = await new Promise((resolve, reject) => {
|
|||
);
|
||||
|
||||
it.each(["wrapper", "root package preloads"])(
|
||||
"keeps %s and raw tsx children off disk caches without changing other cache settings",
|
||||
"keeps %s and descendants off disk caches without changing other cache settings",
|
||||
async (entrypoint) => {
|
||||
await withShimFixture(TSX_SHIM_WRAPPERS[0], async (fixture) => {
|
||||
const { fixtureRoot, implementationPath, wrapperPath, runNode } = fixture;
|
||||
|
|
@ -287,22 +287,36 @@ fs.readdirSync = function (directory, ...args) {
|
|||
key === "TMPDIR" || key === "TEMP" ? tempRoot : path.join(fixtureRoot, key),
|
||||
]),
|
||||
);
|
||||
const childPath = path.join(fixtureRoot, "child.mts");
|
||||
const snapshotSource = `
|
||||
const childPath = path.join(fixtureRoot, "child.mjs");
|
||||
const environmentSnapshot = `
|
||||
env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])),
|
||||
tsxDisableCache: process.env.TSX_DISABLE_CACHE,
|
||||
`;
|
||||
const transformedSnapshotSource = `
|
||||
enum Transformed { Value = "transformed" }
|
||||
console.log(JSON.stringify({
|
||||
transformed: Transformed.Value,
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])),
|
||||
${environmentSnapshot}
|
||||
}));
|
||||
`;
|
||||
writeFileSync(childPath, `${snapshotSource}\nprocess.exitCode = 17;\n`);
|
||||
writeFileSync(
|
||||
childPath,
|
||||
`console.log(JSON.stringify({
|
||||
transformed: null,
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
${environmentSnapshot}
|
||||
}));
|
||||
process.exitCode = 17;
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
implementationPath,
|
||||
`${snapshotSource}
|
||||
`${transformedSnapshotSource}
|
||||
import { spawnSync } from "node:child_process";
|
||||
const child = spawnSync(process.execPath, ["--import", "tsx", ${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" });
|
||||
const child = spawnSync(process.execPath, [${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" });
|
||||
if (child.error) throw child.error;
|
||||
process.exitCode = child.status ?? 1;
|
||||
`,
|
||||
|
|
@ -346,14 +360,22 @@ process.exitCode = child.status ?? 1;
|
|||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual(
|
||||
Array.from({ length: 2 }, () => ({
|
||||
).toEqual([
|
||||
{
|
||||
transformed: "transformed",
|
||||
args: ["argument with spaces", "--proof"],
|
||||
cwd: process.cwd(),
|
||||
env: preservedEnv,
|
||||
})),
|
||||
);
|
||||
tsxDisableCache: "1",
|
||||
},
|
||||
{
|
||||
transformed: null,
|
||||
args: ["argument with spaces", "--proof"],
|
||||
cwd: process.cwd(),
|
||||
env: preservedEnv,
|
||||
tsxDisableCache: "1",
|
||||
},
|
||||
]);
|
||||
if (entrypoint === "wrapper") {
|
||||
expect(result.stderr.trim().split("\n").at(-1)).toBe("[test] FAILED (exit 17)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,10 @@ const EXCLUDED_CASES: readonly CornerCase[] = [
|
|||
|
||||
const ALL_CASES = [...CORNER_CASES, ...ROUND_CASES, ...EXCLUDED_CASES];
|
||||
|
||||
// CSSOM can serialize the same circular shape as round or superellipse(1).
|
||||
// https://drafts.csswg.org/css-borders-4/#valdef-corner-shape-value-round
|
||||
const CIRCULAR_SHAPE = expect.stringMatching(/^(?:round|superellipse\(1\))$/);
|
||||
|
||||
// The radius tokens themselves, read at :root exactly like
|
||||
// collectMcpAppStyleVariables() in mcp-app-theme.ts reads them for embedded
|
||||
// MCP apps. They must stay canonical/unscaled even under the superelliptical
|
||||
|
|
@ -334,11 +338,11 @@ describeCornerShape("Control UI corner curvature", () => {
|
|||
]),
|
||||
...ROUND_CASES.map((corner) => [
|
||||
corner.selector,
|
||||
{ radius: corner.superelliptical, shape: "round" },
|
||||
{ radius: corner.superelliptical, shape: CIRCULAR_SHAPE },
|
||||
]),
|
||||
...EXCLUDED_CASES.map((corner) => [
|
||||
corner.selector,
|
||||
{ radius: corner.superelliptical, shape: "round" },
|
||||
{ radius: corner.superelliptical, shape: CIRCULAR_SHAPE },
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
|
@ -349,7 +353,10 @@ describeCornerShape("Control UI corner curvature", () => {
|
|||
|
||||
expect(probe).toEqual(
|
||||
Object.fromEntries(
|
||||
ALL_CASES.map((corner) => [corner.selector, { radius: corner.circular, shape: "round" }]),
|
||||
ALL_CASES.map((corner) => [
|
||||
corner.selector,
|
||||
{ radius: corner.circular, shape: CIRCULAR_SHAPE },
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue