diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f063703dc..2e0b4f1a221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. - Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis. - UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code. diff --git a/scripts/release-beta-smoke.ts b/scripts/release-beta-smoke.ts index 3489a234dbe..40e34a646fd 100644 --- a/scripts/release-beta-smoke.ts +++ b/scripts/release-beta-smoke.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; interface Options { beta: string; @@ -101,6 +102,8 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } +const TELEGRAM_BETA_WORKFLOW_FILE = "npm-telegram-beta-e2e.yml"; + function resolveBetaVersion(beta: string): string { const value = beta.trim().replace(/^openclaw@/, ""); if (/^\d{4}\.\d+\.\d+-beta\.\d+$/u.test(value)) { @@ -160,13 +163,92 @@ function ghJson(repo: string, pathSuffix: string): unknown { return JSON.parse(run("gh", ["api", `repos/${repo}/${pathSuffix}`], { capture: true })); } -function dispatchTelegram(options: Options, packageSpec: string): string { +export function parseWorkflowRunIdFromOutput(output: string): string | undefined { + return /\/actions\/runs\/(\d+)/u.exec(output)?.[1]; +} + +type WorkflowRunListEntry = { + createdAt?: string; + databaseId?: number | string; +}; + +function normalizeRunId(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + return undefined; +} + +export function selectNewestDispatchedRunId(params: { + beforeIds: ReadonlySet; + runs: readonly WorkflowRunListEntry[]; +}): string | undefined { + return params.runs + .filter((entry) => { + const id = normalizeRunId(entry.databaseId); + return id !== undefined && !params.beforeIds.has(id); + }) + .toSorted((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")) + .map((entry) => normalizeRunId(entry.databaseId)) + .find((id): id is string => id !== undefined); +} + +function listWorkflowDispatchRuns(repo: string, workflow: string): WorkflowRunListEntry[] { + return JSON.parse( + run( + "gh", + [ + "run", + "list", + "--repo", + repo, + "--workflow", + workflow, + "--event", + "workflow_dispatch", + "--limit", + "50", + "--json", + "databaseId,createdAt", + ], + { capture: true }, + ), + ) as WorkflowRunListEntry[]; +} + +async function findDispatchedWorkflowRunId(params: { + beforeIds: ReadonlySet; + repo: string; + workflow: string; +}): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const runId = selectNewestDispatchedRunId({ + beforeIds: params.beforeIds, + runs: listWorkflowDispatchRuns(params.repo, params.workflow), + }); + if (runId) { + return runId; + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`could not find dispatched run for ${params.workflow}`); +} + +async function dispatchTelegram(options: Options, packageSpec: string): Promise { + const beforeIds = new Set( + listWorkflowDispatchRuns(options.repo, TELEGRAM_BETA_WORKFLOW_FILE) + .map((entry) => normalizeRunId(entry.databaseId)) + .filter((id): id is string => id !== undefined), + ); const output = run( "gh", [ "workflow", "run", - "NPM Telegram Beta E2E", + TELEGRAM_BETA_WORKFLOW_FILE, "--repo", options.repo, "--ref", @@ -180,11 +262,15 @@ function dispatchTelegram(options: Options, packageSpec: string): string { ], { capture: true }, ); - const runId = /\/actions\/runs\/(\d+)/u.exec(output)?.[1]; - if (!runId) { - throw new Error(`could not parse workflow run id from gh output:\n${output}`); + const runId = parseWorkflowRunIdFromOutput(output); + if (runId) { + return runId; } - return runId; + return await findDispatchedWorkflowRunId({ + beforeIds, + repo: options.repo, + workflow: TELEGRAM_BETA_WORKFLOW_FILE, + }); } async function pollRun(repo: string, runId: string): Promise { @@ -266,7 +352,7 @@ async function main(): Promise { } if (!options.skipTelegram) { - const runId = dispatchTelegram(options, packageSpec); + const runId = await dispatchTelegram(options, packageSpec); await pollRun(options.repo, runId); const artifactDir = downloadTelegramArtifact(options.repo, runId); const report = findFile(artifactDir, "telegram-qa-report.md"); @@ -277,7 +363,9 @@ async function main(): Promise { } } -await main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/test/scripts/release-beta-smoke.test.ts b/test/scripts/release-beta-smoke.test.ts new file mode 100644 index 00000000000..073fc57ca66 --- /dev/null +++ b/test/scripts/release-beta-smoke.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowRunIdFromOutput, + selectNewestDispatchedRunId, +} from "../../scripts/release-beta-smoke.ts"; + +describe("release-beta-smoke", () => { + it("parses workflow run urls when gh includes them in dispatch output", () => { + expect( + parseWorkflowRunIdFromOutput( + "Dispatched: https://github.com/openclaw/openclaw/actions/runs/1234567890", + ), + ).toBe("1234567890"); + }); + + it("selects the newest workflow_dispatch run not present before dispatch", () => { + const beforeIds = new Set(["100", "101"]); + + expect( + selectNewestDispatchedRunId({ + beforeIds, + runs: [ + { databaseId: 100, createdAt: "2026-05-04T10:00:00Z" }, + { databaseId: 102, createdAt: "2026-05-04T10:01:00Z" }, + { databaseId: 103, createdAt: "2026-05-04T10:02:00Z" }, + ], + }), + ).toBe("103"); + }); +});