ci: make release child dispatch idempotent (#102858)

(cherry picked from commit 11dda0e5fd)
This commit is contained in:
Peter Steinberger
2026-07-09 15:03:46 +01:00
committed by Dallin Romney
parent 5e02ed4048
commit 2d7c97028e
8 changed files with 275 additions and 61 deletions
+7 -1
View File
@@ -95,8 +95,14 @@ describe("ci workflow guards", () => {
default: false,
type: "boolean",
});
expect(workflow.on.workflow_dispatch.inputs.dispatch_id).toEqual({
description: "Optional parent workflow dispatch identifier",
required: false,
default: "",
type: "string",
});
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
"run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_gate && format('CI release gate {0}', inputs.target_ref) || 'CI' }}",
"run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_id != '' && format('CI {0}', inputs.dispatch_id) || (github.event_name == 'workflow_dispatch' && inputs.release_gate && format('CI release gate {0}', inputs.target_ref) || 'CI') }}",
);
const preflightSteps = workflow.jobs.preflight.steps;
const validationStep = preflightSteps.find(
@@ -543,18 +543,16 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain(
'[[ "$CHILD_WORKFLOW_REF" == release-ci/* && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]',
);
expect(workflow).toContain(
'gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"',
);
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
expect(performanceJob).toContain(
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"',
);
expect(performanceJob).toContain('-f dispatch_id="$dispatch_id"');
expect(performanceJob).toContain(
'DISPATCH_RUN_NAME="$dispatch_run_name" gh_with_retry api -X GET',
'DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF"',
);
expect(performanceJob).toContain(".display_title == env.DISPATCH_RUN_NAME");
expect(performanceJob).toContain("Could not find dispatched run for ${dispatch_run_name}.");
expect(performanceJob).toContain("Could not find exact dispatched run ${dispatch_run_name}");
expect(performanceJob).not.toContain("BEFORE_IDS=");
expect(performanceJob).not.toContain(
"did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs",
@@ -573,6 +571,55 @@ describe("package acceptance workflow", () => {
);
});
it("adopts exact full-release child runs without retrying ambiguous dispatch posts", () => {
const childDispatches = [
["normal_ci", "Dispatch and monitor CI"],
["plugin_prerelease", "Dispatch and monitor plugin prerelease"],
["release_checks", "Dispatch and monitor release checks"],
["npm_telegram", "Dispatch and monitor npm Telegram E2E"],
["performance", "Dispatch and monitor OpenClaw Performance"],
] as const;
for (const [jobName, stepName] of childDispatches) {
const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName);
const script = workflowStep(job, stepName).run ?? "";
expect(script.match(/gh workflow run/gu)).toHaveLength(1);
expect(script).not.toContain("gh_with_retry workflow run");
expectTextToIncludeAll(script, [
"A failed dispatch POST can still create a run. Never retry it",
"set +e",
"dispatch_status=$?",
'DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF"',
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
"Multiple runs matched ${dispatch_run_name}; refusing to guess.",
"The dispatch was not retried to avoid creating a duplicate child.",
"adopted exact run ${run_id}",
]);
}
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
expectTextToIncludeAll(workflow, [
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci"',
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease"',
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks"',
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"',
'args+=(-f dispatch_id="$dispatch_id")',
]);
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
"format('CI {0}', inputs.dispatch_id)",
);
expect(readFileSync(".github/workflows/plugin-prerelease.yml", "utf8")).toContain(
"format('Plugin Prerelease {0}', inputs.dispatch_id)",
);
expect(readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8")).toContain(
"format('OpenClaw Release Checks {0}', inputs.dispatch_id)",
);
expect(readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8")).toContain(
"format('NPM Telegram Beta E2E {0}', inputs.dispatch_id)",
);
});
it("keeps exhaustive update migration as a separate manual package gate", () => {
const workflow = readFileSync(UPDATE_MIGRATION_WORKFLOW, "utf8");
const packageWorkflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8");
@@ -1603,9 +1650,7 @@ describe("package artifact reuse", () => {
const dispatchStep = workflowStep(npmTelegramJob, "Dispatch and monitor npm Telegram E2E");
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
expect(workflow).toContain(
'gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"',
);
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
expect(npmTelegramJob.name).toBe("Run package Telegram E2E");
expect(npmTelegramJob.needs).toEqual(["resolve_target"]);
expect(npmTelegramJob["timeout-minutes"]).toBe(
@@ -1625,9 +1670,10 @@ describe("package artifact reuse", () => {
TARGET_SHA: "${{ needs.resolve_target.outputs.sha }}",
});
expectTextToIncludeAll(dispatchStep.run, [
'dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"',
"sed -nE 's#.*actions/runs/([0-9]+).*#\\1#p'",
"did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs",
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"',
'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"',
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
"The dispatch was not retried to avoid creating a duplicate child.",
'-f harness_ref="$TARGET_SHA"',
'args=(-f package_spec="$PACKAGE_SPEC"',
'args+=(-f scenario="$SCENARIO")',
@@ -372,11 +372,11 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
workflow.jobs["check-shard"].steps.find((step) => step.name === "Run check shard").run,
).toContain("pnpm deadcode:ci");
expect(normalCiScript).toContain(
'dispatch_and_wait ci.yml -f target_ref="$TARGET_SHA" -f include_android=true',
'dispatch_and_wait ci.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id"',
);
expect(normalCiScript).not.toContain("full_release_validation=true");
expect(pluginPrereleaseScript).toContain(
'dispatch_and_wait plugin-prerelease.yml -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true',
'dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id"',
);
expect(pluginManifestScript).toContain("await import(");
expect(pluginManifestScript).toContain('"./scripts/lib/plugin-prerelease-test-plan.mjs"');
@@ -398,6 +398,12 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
required: false,
type: "boolean",
});
expect(pluginWorkflow.on.workflow_dispatch.inputs.dispatch_id).toEqual({
description: "Optional parent workflow dispatch identifier",
required: false,
default: "",
type: "string",
});
expect(pluginManifestEnv).toEqual({
EXPECTED_SHA: "${{ inputs.expected_sha }}",
FULL_RELEASE_VALIDATION: "${{ inputs.full_release_validation && 'true' || 'false' }}",