fix(ci): release workflow checks fail on macOS Bash 3.2 (#121669)

* fix(ci): keep release workflow checks portable on macOS

* fix(ci): repair current main compact shard regressions

* fix(ci): repair latest main validation drift

* fix(approvals): restore native account ownership gates

* fix(ci): repair skill workshop validation drift

* fix(ci): align approval route selection with main

* fix(ci): remove stale skill workshop test exports

* fix(ci): align repairs with latest main
This commit is contained in:
Peter Steinberger
2026-08-10 16:11:53 -07:00
committed by GitHub
parent d6317094a9
commit 3cd034f7a8
10 changed files with 101 additions and 69 deletions
@@ -507,7 +507,7 @@ jobs:
done
if [[ "$qa_filter_seen" == "true" ]]; then
repo_live_suite_filter="$(IFS=,; printf '%s' "${repo_filter_tokens[*]:-}")"
repo_live_suite_filter="$(IFS=,; printf '%s' "${repo_filter_tokens[*]-}")"
fi
if [[ "${#disabled_required_lanes[@]}" -gt 0 ]]; then
@@ -135,7 +135,12 @@ describe("executeAgentTurn: lifecycle progress", () => {
name: "read",
phase: "start",
status: "running",
summary: undefined,
progressText: undefined,
meta: undefined,
commandBearing: false,
approvalId: undefined,
approvalSlug: undefined,
});
});
@@ -227,7 +232,12 @@ describe("executeAgentTurn: lifecycle progress", () => {
name: "bash",
phase: "start",
status: "running",
summary: undefined,
progressText: undefined,
meta: undefined,
commandBearing: false,
approvalId: undefined,
approvalSlug: undefined,
});
});
+13 -10
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { setupCronServiceSuite } from "./service.test-harness.js";
import type { CronServiceDeps } from "./service/state.js";
const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-stream-trigger-" });
@@ -194,7 +195,9 @@ describe("cron stream trigger composition", () => {
it("reports a failed payload batch without reporting it fired", async () => {
const { storePath } = await makeStorePath();
const onTriggerDisposition = vi.fn();
const sendCronFailureAlert = vi.fn(async () => undefined);
const sendCronFailureAlert = vi.fn<NonNullable<CronServiceDeps["sendCronFailureAlert"]>>(
async () => undefined,
);
const cron = new CronService({
storePath,
cronEnabled: true,
@@ -239,15 +242,15 @@ describe("cron stream trigger composition", () => {
consecutiveErrors: 1,
});
expect(sendCronFailureAlert).toHaveBeenCalledOnce();
expect(sendCronFailureAlert).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
text:
'Automation "failing stream payload" failed 1 times\n' +
"Check automation history for details.",
}),
}),
);
const alert = sendCronFailureAlert.mock.calls[0]?.[0];
expect(alert?.channel).toBe("telegram");
expect(alert?.to).toBe("19098680");
expect(alert?.payload).toEqual({
text:
'Automation "failing stream payload" failed 1 times\n' +
"Check automation history for details.",
});
expect(alert?.job.state.lastError).toBe("boom");
} finally {
cron.stop();
}
+23 -15
View File
@@ -16,7 +16,7 @@ import type {
CronAgentExecutionStarted,
CronJob,
} from "../types.js";
import { createCronServiceState } from "./state.js";
import { createCronServiceState, type CronServiceDeps } from "./state.js";
import { onTimer } from "./timer.test-support.js";
const timerRegressionFixtures = setupCronRegressionFixtures({
@@ -723,7 +723,9 @@ describe("cron service timer regressions", () => {
const started = createDeferred();
let abortObserved = false;
const cleanupTimedOutAgentRun = vi.fn(async () => {});
const sendCronFailureAlert = vi.fn(async () => {});
const sendCronFailureAlert = vi.fn<NonNullable<CronServiceDeps["sendCronFailureAlert"]>>(
async () => {},
);
const state = createCronServiceState({
cronEnabled: true,
storePath: store.storePath,
@@ -775,23 +777,29 @@ describe("cron service timer regressions", () => {
await timerPromise;
const job = requireJob(state, "isolated-before-agent-reply-unhandled-82811");
const diagnostic =
"cron: isolated agent run stalled before execution start (last phase: runtime-plugins)";
expect(abortObserved).toBe(true);
expect(job.state.lastStatus).toBe("error");
expect(job.state.lastError).toContain("stalled before execution start");
expect(job.state.lastError).toContain("runtime-plugins");
expect(job.state.lastError).toBe(diagnostic);
expect(job.state.lastDiagnosticSummary).toBe(diagnostic);
expect(job.state.lastDiagnostics).toEqual({
summary: diagnostic,
entries: [
{ source: "cron-setup", severity: "error", message: diagnostic, ts: scheduledAt },
],
});
expect(cleanupTimedOutAgentRun).toHaveBeenCalledTimes(1);
expect(sendCronFailureAlert).toHaveBeenCalledTimes(1);
expect(sendCronFailureAlert).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "12345",
payload: expect.objectContaining({
text:
'Automation "before agent reply unhandled regression" failed 1 times\n' +
"Check automation history for details.",
}),
}),
);
const alert = sendCronFailureAlert.mock.calls[0]?.[0];
expect(alert?.channel).toBe("telegram");
expect(alert?.to).toBe("12345");
expect(alert?.payload).toEqual({
text:
'Automation "before agent reply unhandled regression" failed 1 times\n' +
"Check automation history for details.",
});
expect(alert?.job.state.lastDiagnosticSummary).toBe(diagnostic);
} finally {
vi.useRealTimers();
}
@@ -28,6 +28,7 @@ export async function runExclusiveSystemAgentSetupActivation<T>(
}
}
/** Resolves after both the wizard runner and its setup-target admission have settled. */
export function whenAdmittedWizardSessionSettled(session: {
whenSettled(): Promise<unknown>;
}): Promise<unknown> {
+5 -19
View File
@@ -250,26 +250,12 @@ describe("resolveApprovalOverGateway", () => {
it("sends channel custody to an injected canonical runtime", async () => {
const injectedRequest = vi.fn(async () => ({ applied: true, approval: recordedApproval }));
const scopedRequest: GatewayNativeApprovalRuntime["request"] = vi.fn(
async <T = unknown>(method: string): Promise<T> => {
const fixture =
method === "exec.approval.list"
? [
{
id: "approval-1",
request: {
command: "printf approval",
turnSourceChannel: "imessage",
turnSourceAccountId: "personal",
},
},
]
: { applied: true, approval: recordedApproval };
return fixture as T;
},
) as GatewayNativeApprovalRuntime["request"];
const scopedRequest = vi.fn();
const runtime = {
request: scopedRequest,
request: async <T>(): Promise<T> => {
scopedRequest();
throw new Error("unexpected scoped approval request");
},
requestRoute: vi.fn(),
routeCoordinator: { doesAccountHandleRequest: () => true } as never,
subscribe: vi.fn(),
@@ -773,6 +773,8 @@ export function createNativeApprovalChannelRouteGates<TTarget extends NativeAppr
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): boolean => {
// Per-account runtimes report raw candidates here. The route coordinator rejects
// unbound multi-account groups as ambiguous before any runtime can deliver.
const accountId = input.accountId ?? params.resolveDefaultAccountId(input.cfg);
const eligibleAccountIds = params.isTransportEnabled({ cfg: input.cfg, accountId })
? [accountId]
@@ -20,10 +20,28 @@ import { getArchivedSkillFiles } from "./curator.js";
import { readSkillProposalTargetTreeSha256 } from "./proposal-bundle.js";
import { withSkillCollectionLock } from "./target-lock.js";
type CopyDirectoryHook = (
source: unknown,
destination: unknown,
options?: unknown,
) => Promise<void>;
const copyDirectoryBefore = vi.hoisted(() => vi.fn<CopyDirectoryHook>(async () => {}));
const copyDirectoryAfter = vi.hoisted(() => vi.fn<CopyDirectoryHook>(async () => {}));
const dispatchCommittedSkillChangeBestEffort = vi.hoisted(() =>
vi.fn(async (_event: { action: string }) => {}),
);
const snapshotCommittedSkillArtifactBestEffort = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
const cp: typeof actual.cp = async (source, destination, options) => {
await copyDirectoryBefore(source, destination, options);
await actual.cp(source, destination, options);
await copyDirectoryAfter(source, destination, options);
};
const patched = { ...actual, cp };
return { ...patched, default: patched };
});
vi.mock("../lifecycle/skill-change-hook.js", () => ({
hasCommittedSkillChangeHooks: () => true,
snapshotCommittedSkillArtifactBestEffort,
@@ -35,6 +53,10 @@ let testState: OpenClawTestState;
let workspaceDir: string;
beforeEach(async () => {
copyDirectoryBefore.mockReset();
copyDirectoryBefore.mockResolvedValue(undefined);
copyDirectoryAfter.mockReset();
copyDirectoryAfter.mockResolvedValue(undefined);
dispatchCommittedSkillChangeBestEffort.mockClear();
snapshotCommittedSkillArtifactBestEffort.mockReset();
snapshotCommittedSkillArtifactBestEffort.mockResolvedValue(undefined);
@@ -239,9 +261,7 @@ describe("skill collection reconciliation", () => {
await fs.mkdir(path.dirname(supportFile), { recursive: true });
await fs.writeFile(supportFile, "Before\n", "utf8");
const receipt = await readCollectionReceipt();
const copy = fs.cp.bind(fs);
const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => {
await copy(source, destination, options);
copyDirectoryAfter.mockImplementationOnce(async () => {
await fs.appendFile(supportFile, "External edit\n", "utf8");
});
@@ -260,7 +280,7 @@ describe("skill collection reconciliation", () => {
],
}),
).rejects.toThrow("Skill tree changed before collection mutation: procedure");
copySpy.mockRestore();
copyDirectoryAfter.mockReset();
await expect(fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).resolves.toContain(
"# Original",
@@ -450,12 +470,16 @@ describe("skill collection reconciliation", () => {
},
],
});
const skillDir = path.join(workspaceDir, "skills", "procedure");
const canonicalWorkspaceDir = await fs.realpath(workspaceDir);
const skillDir = path.join(canonicalWorkspaceDir, "skills", "procedure");
const skillFile = path.join(skillDir, "SKILL.md");
const backupRoot = path.join(testState.stateDir, "skill-workshop", "collection-backups");
const originalCopy = fs.cp.bind(fs);
const backupRoot = path.join(
await fs.realpath(testState.stateDir),
"skill-workshop",
"collection-backups",
);
let failed = false;
const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => {
copyDirectoryBefore.mockImplementation(async (source, destination) => {
if (
!failed &&
String(source).startsWith(backupRoot) &&
@@ -465,7 +489,6 @@ describe("skill collection reconciliation", () => {
failed = true;
throw new Error("forced restore copy failure");
}
await originalCopy(source, destination, options);
});
try {
@@ -473,7 +496,7 @@ describe("skill collection reconciliation", () => {
restoreLatestSkillCollectionBackup({ workspaceDir, env: testState.env }),
).rejects.toThrow("forced restore copy failure");
} finally {
copySpy.mockRestore();
copyDirectoryBefore.mockReset();
}
await expect(fs.readFile(skillFile, "utf8")).resolves.toContain("# Clean");
@@ -498,14 +521,12 @@ describe("skill collection reconciliation", () => {
},
],
});
const skillDir = path.join(workspaceDir, "skills", "procedure");
const skillDir = path.join(await fs.realpath(workspaceDir), "skills", "procedure");
const beforeVersion = getSkillsSnapshotVersion();
const originalCopy = fs.cp.bind(fs);
const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => {
copyDirectoryBefore.mockImplementation(async (source, destination) => {
if (path.resolve(String(destination)) === path.resolve(skillDir)) {
throw new Error(`forced restore copy failure: ${String(source)}`);
}
await originalCopy(source, destination, options);
});
try {
@@ -513,7 +534,7 @@ describe("skill collection reconciliation", () => {
restoreLatestSkillCollectionBackup({ workspaceDir, env: testState.env }),
).rejects.toThrow("current collection was not restored");
} finally {
copySpy.mockRestore();
copyDirectoryBefore.mockReset();
}
expect(getSkillsSnapshotVersion()).toBeGreaterThan(beforeVersion);
@@ -367,6 +367,7 @@ describe("skill collection review", () => {
it("groups symlink aliases before comparing shared-workspace identities", async () => {
const workspaceDir = await tempDirs.make("openclaw-collection-review-real-workspace-");
const canonicalWorkspaceDir = await fs.realpath(workspaceDir);
const aliasParent = await tempDirs.make("openclaw-collection-review-alias-parent-");
const workspaceAlias = path.join(aliasParent, "workspace-alias");
await fs.symlink(
@@ -400,7 +401,7 @@ describe("skill collection review", () => {
onError,
});
expect(onError).toHaveBeenCalledWith(expect.any(Error), workspaceDir);
expect(onError).toHaveBeenCalledWith(expect.any(Error), canonicalWorkspaceDir);
expect(runWithGatewayIndependentRootWorkAdmission).not.toHaveBeenCalled();
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
@@ -450,6 +451,7 @@ describe("skill collection review", () => {
it("admits and reports each workspace independently", async () => {
const oversizedWorkspace = await tempDirs.make("openclaw-collection-review-failed-");
const canonicalOversizedWorkspace = await fs.realpath(oversizedWorkspace);
const healthyWorkspace = await tempDirs.make("openclaw-collection-review-healthy-");
await writeWorkspaceSkills(oversizedWorkspace, [
{ name: "oversized", description: "Oversized", body: "x".repeat(240_001) },
@@ -489,7 +491,7 @@ describe("skill collection review", () => {
});
expect(runWithGatewayIndependentRootWorkAdmission).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledWith(expect.any(Error), oversizedWorkspace);
expect(onError).toHaveBeenCalledWith(expect.any(Error), canonicalOversizedWorkspace);
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
});
@@ -3871,7 +3871,7 @@ describe("package artifact reuse", () => {
expect(workflow).toContain("repo_live_suite_filter:");
expect(workflow).toContain('repo_filter_tokens+=("$token")');
expect(workflow).toContain(
'repo_live_suite_filter="$(IFS=,; printf \'%s\' "${repo_filter_tokens[*]:-}")"',
'repo_live_suite_filter="$(IFS=,; printf \'%s\' "${repo_filter_tokens[*]-}")"',
);
expect(workflow).toContain("cross_os_suite_filter:");
expect(workflow).toContain("advisory: false");
@@ -6356,12 +6356,11 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
const releaseChecksParent = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "release_checks");
expect(releaseChecksParent["runs-on"]).toBe("blacksmith-4vcpu-ubuntu-2404");
expect(releaseChecksParent["timeout-minutes"]).toBe(420);
const releasePackageTimeouts = Object.fromEntries(
profiles.map((profile) => [
profile,
releasePackagePaths[profile].reduce((total, timeout) => total + timeout, 0),
]),
) as Record<(typeof profiles)[number], number>;
const releasePackageTimeouts = {
beta: releasePackagePaths.beta.reduce((total, timeout) => total + timeout, 0),
stable: releasePackagePaths.stable.reduce((total, timeout) => total + timeout, 0),
full: releasePackagePaths.full.reduce((total, timeout) => total + timeout, 0),
};
expect(releasePackageTimeouts).toEqual({ beta: 280, stable: 280, full: 310 });
for (const [profile, childTimeout] of Object.entries(releasePackageTimeouts)) {
expect(childTimeout, `release-package:${profile}`).toBeLessThanOrEqual(420);