diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306fc1ccf5b5..ba8f026ad36f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,11 @@ on: required: false default: false type: boolean + dispatch_id: + description: Optional parent workflow dispatch identifier + required: false + default: "" + type: string push: branches: [main] paths-ignore: @@ -31,7 +36,7 @@ on: permissions: contents: read -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') }} concurrency: group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }} diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 16618613945c..fe5765d0ee6a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -275,9 +275,10 @@ jobs: dispatch_and_wait() { local workflow="$1" - shift + local dispatch_run_name="$2" + shift 2 - local dispatch_output run_id status conclusion url poll_count + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -300,18 +301,42 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } - dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")" + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. + set +e + dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" + dispatch_status=$? + set -e printf '%s\n' "$dispatch_output" - run_id="$( - printf '%s\n' "$dispatch_output" | - sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | - tail -n 1 - )" + + run_id="" + for _ in $(seq 1 60); do + if matches_json="$( + DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ + gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -F event=workflow_dispatch \ + -F per_page=100 \ + --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' + )"; then + match_count="$(jq 'length' <<< "$matches_json")" + if (( match_count > 1 )); then + echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 + exit 1 + fi + if (( match_count == 1 )); then + run_id="$(jq -r '.[0]' <<< "$matches_json")" + break + fi + fi + sleep 5 + done if [[ -z "$run_id" ]]; then - echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2 + echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 fi + if [[ "$dispatch_status" -ne 0 ]]; then + echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 + fi echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" @@ -383,7 +408,9 @@ jobs: echo "- Target SHA: \`${TARGET_SHA}\`" } >> "$GITHUB_STEP_SUMMARY" - dispatch_and_wait ci.yml -f target_ref="$TARGET_SHA" -f include_android=true + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" + dispatch_run_name="CI ${dispatch_id}" + dispatch_and_wait ci.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id" plugin_prerelease: name: Run plugin prerelease validation @@ -408,9 +435,10 @@ jobs: dispatch_and_wait() { local workflow="$1" - shift + local dispatch_run_name="$2" + shift 2 - local dispatch_output run_id status conclusion url poll_count + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -433,18 +461,42 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } - dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")" + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. + set +e + dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" + dispatch_status=$? + set -e printf '%s\n' "$dispatch_output" - run_id="$( - printf '%s\n' "$dispatch_output" | - sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | - tail -n 1 - )" + + run_id="" + for _ in $(seq 1 60); do + if matches_json="$( + DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ + gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -F event=workflow_dispatch \ + -F per_page=100 \ + --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' + )"; then + match_count="$(jq 'length' <<< "$matches_json")" + if (( match_count > 1 )); then + echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 + exit 1 + fi + if (( match_count == 1 )); then + run_id="$(jq -r '.[0]' <<< "$matches_json")" + break + fi + fi + sleep 5 + done if [[ -z "$run_id" ]]; then - echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2 + echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 fi + if [[ "$dispatch_status" -ne 0 ]]; then + echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 + fi echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" @@ -516,7 +568,9 @@ jobs: echo "- Target SHA: \`${TARGET_SHA}\`" } >> "$GITHUB_STEP_SUMMARY" - dispatch_and_wait plugin-prerelease.yml -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" + dispatch_run_name="Plugin Prerelease ${dispatch_id}" + 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" release_checks: name: Run release/live/Docker/QA validation @@ -551,9 +605,10 @@ jobs: dispatch_and_wait() { local workflow="$1" - shift + local dispatch_run_name="$2" + shift 2 - local dispatch_output run_id status conclusion url poll_count run_json + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -576,18 +631,42 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } - dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")" + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. + set +e + dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" + dispatch_status=$? + set -e printf '%s\n' "$dispatch_output" - run_id="$( - printf '%s\n' "$dispatch_output" | - sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | - tail -n 1 - )" + + run_id="" + for _ in $(seq 1 60); do + if matches_json="$( + DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ + gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -F event=workflow_dispatch \ + -F per_page=100 \ + --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' + )"; then + match_count="$(jq 'length' <<< "$matches_json")" + if (( match_count > 1 )); then + echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 + exit 1 + fi + if (( match_count == 1 )); then + run_id="$(jq -r '.[0]' <<< "$matches_json")" + break + fi + fi + sleep 5 + done if [[ -z "$run_id" ]]; then - echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2 + echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 fi + if [[ "$dispatch_status" -ne 0 ]]; then + echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 + fi echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" @@ -764,7 +843,10 @@ jobs: args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") fi - dispatch_and_wait openclaw-release-checks.yml "${args[@]}" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" + dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" npm_telegram: name: Run package Telegram E2E @@ -818,18 +900,46 @@ jobs: args+=(-f scenario="$SCENARIO") fi - dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" + dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. + set +e + dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)" + dispatch_status=$? + set -e printf '%s\n' "$dispatch_output" - run_id="$( - printf '%s\n' "$dispatch_output" | - sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' | - tail -n 1 - )" + + run_id="" + for _ in $(seq 1 60); do + if matches_json="$( + DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ + gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/npm-telegram-beta-e2e.yml/runs" \ + -F event=workflow_dispatch \ + -F per_page=100 \ + --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' + )"; then + match_count="$(jq 'length' <<< "$matches_json")" + if (( match_count > 1 )); then + echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 + exit 1 + fi + if (( match_count == 1 )); then + run_id="$(jq -r '.[0]' <<< "$matches_json")" + break + fi + fi + sleep 5 + done if [[ -z "$run_id" ]]; then - echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2 + echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 fi + if [[ "$dispatch_status" -ne 0 ]]; then + echo "::warning::npm-telegram-beta-e2e.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 + fi echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" @@ -942,7 +1052,9 @@ jobs: dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" dispatch_run_name="OpenClaw Performance ${dispatch_id}" - dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \ + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. + set +e + dispatch_output="$(gh workflow run openclaw-performance.yml \ --ref "$CHILD_WORKFLOW_REF" \ -f target_ref="$TARGET_SHA" \ -f profile=release \ @@ -950,27 +1062,40 @@ jobs: -f deep_profile=false \ -f live_openai_candidate=false \ -f fail_on_regression=true \ - -f dispatch_id="$dispatch_id")" + -f dispatch_id="$dispatch_id" 2>&1)" + dispatch_status=$? + set -e printf '%s\n' "$dispatch_output" run_id="" for _ in $(seq 1 60); do - run_id="$( - DISPATCH_RUN_NAME="$dispatch_run_name" gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-performance.yml/runs" \ + if matches_json="$( + DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ + gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-performance.yml/runs" \ -F event=workflow_dispatch \ -F per_page=100 \ - --jq '.workflow_runs | map(select(.display_title == env.DISPATCH_RUN_NAME)) | sort_by(.created_at) | reverse | .[0].id // empty' - )" - if [[ -n "$run_id" ]]; then - break + --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' + )"; then + match_count="$(jq 'length' <<< "$matches_json")" + if (( match_count > 1 )); then + echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 + exit 1 + fi + if (( match_count == 1 )); then + run_id="$(jq -r '.[0]' <<< "$matches_json")" + break + fi fi sleep 5 done if [[ -z "$run_id" ]]; then - echo "::error::Could not find dispatched run for ${dispatch_run_name}." >&2 + echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 fi + if [[ "$dispatch_status" -ne 0 ]]; then + echo "::warning::openclaw-performance.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 + fi echo "Dispatched openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index 4d734fb95c28..ad257f6294f0 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -1,5 +1,7 @@ name: NPM Telegram Beta E2E +run-name: ${{ inputs.dispatch_id != '' && format('NPM Telegram Beta E2E {0}', inputs.dispatch_id) || 'NPM Telegram Beta E2E' }} + on: workflow_dispatch: inputs: @@ -45,6 +47,11 @@ on: required: false default: false type: boolean + dispatch_id: + description: Optional parent workflow dispatch identifier + required: false + default: "" + type: string workflow_call: inputs: advisory: @@ -86,6 +93,11 @@ on: required: false default: "" type: string + dispatch_id: + description: Optional parent workflow dispatch identifier + required: false + default: "" + type: string secrets: OPENAI_API_KEY: required: false diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 7ccf77368afc..9c08b0d01868 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -1,5 +1,7 @@ name: OpenClaw Release Checks +run-name: ${{ inputs.dispatch_id != '' && format('OpenClaw Release Checks {0}', inputs.dispatch_id) || 'OpenClaw Release Checks' }} + on: workflow_dispatch: inputs: @@ -88,6 +90,11 @@ on: required: false default: "" type: string + dispatch_id: + description: Optional parent workflow dispatch identifier + required: false + default: "" + type: string concurrency: group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }} diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index abed4e2439a5..d25f8a934588 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -1,5 +1,7 @@ name: Plugin Prerelease +run-name: ${{ inputs.dispatch_id != '' && format('Plugin Prerelease {0}', inputs.dispatch_id) || 'Plugin Prerelease' }} + on: workflow_dispatch: inputs: @@ -18,6 +20,11 @@ on: required: false default: false type: boolean + dispatch_id: + description: Optional parent workflow dispatch identifier + required: false + default: "" + type: string permissions: contents: read diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 0d53bb9790cb..c35e974e0804 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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( diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index a7e47ebeb7ae..cfaf490ad8a5 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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")', diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index fead3dedfa8c..741157ef0c59 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -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' }}",