diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 800d4ec10323..42d30d721bd6 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -30,10 +30,9 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele entitlement. Mandatory live providers must pass a real completion probe before release dispatch. Fix the credential first; do not add an alternate auth path merely to bypass a failed release credential. -- Full Release Validation parent monitors fail fast: once a required child job - fails, the parent cancels the remaining child matrix and prints the failed - job summary. Inspect that first red job instead of waiting for unrelated - matrix tails. +- Full Release Validation collects independent child failures to terminal + completion by default. Pass `fail_fast=true` only when the shorter + first-failure cancellation path is preferable. - For regular beta/stable releases, treat the product-complete pre-changelog commit as the Code SHA. Full product validation and performance evidence bind to that SHA. The later Release SHA may reuse those results only when it is a diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 51bbb2d596ca..5887ca6d425a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -50,6 +50,11 @@ on: required: false default: false type: boolean + fail_fast: + description: Cancel each child workflow after its first failed job; false collects independent failures to completion + required: false + default: false + type: boolean rerun_group: description: Validation group to run required: false @@ -182,6 +187,7 @@ jobs: CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} RELEASE_PROFILE: ${{ inputs.release_profile }} RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} + FAIL_FAST: ${{ inputs.fail_fast }} ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} @@ -194,6 +200,7 @@ jobs: echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Child workflow ref: \`${CHILD_WORKFLOW_REF}\`" echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Fail fast: \`${FAIL_FAST}\`" echo "- Allow Unreleased changelog packaging: \`${ALLOW_UNRELEASED_CHANGELOG}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then @@ -424,6 +431,7 @@ jobs: TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} + FAIL_FAST: ${{ inputs.fail_fast }} run: | set -euo pipefail @@ -517,6 +525,9 @@ jobs: } fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi local failed_jobs_json if ! failed_jobs_json="$( fetch_child_jobs | @@ -618,6 +629,7 @@ jobs: CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} + FAIL_FAST: ${{ inputs.fail_fast }} run: | set -euo pipefail @@ -711,6 +723,9 @@ jobs: } fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi local failed_jobs_json if ! failed_jobs_json="$( fetch_child_jobs | @@ -812,6 +827,7 @@ jobs: MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} + FAIL_FAST: ${{ inputs.fail_fast }} ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} @@ -956,6 +972,9 @@ jobs: } fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi local failed_jobs_json if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then return 0 @@ -1106,6 +1125,7 @@ jobs: -f mode="$MODE" -f release_profile="$RELEASE_PROFILE" -f run_release_soak="$RUN_RELEASE_SOAK" + -f fail_fast="$FAIL_FAST" -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" -f rerun_group="$child_rerun_group" ) @@ -1158,6 +1178,7 @@ jobs: PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }} PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} SCENARIO: ${{ inputs.npm_telegram_scenario }} + FAIL_FAST: ${{ inputs.fail_fast }} run: | set -euo pipefail @@ -1264,6 +1285,9 @@ jobs: fi fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi local failed_jobs_json failed_jobs_json="$( gh_with_retry run view "$run_id" --json jobs \ diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 71b90fd961ad..1ef2951bb1b9 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -46,6 +46,11 @@ on: required: false default: false type: boolean + fail_fast: + description: Stop the Matrix QA lane after its first failed check or scenario + required: false + default: false + type: boolean run_maturity_scorecard: description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates required: false @@ -133,6 +138,7 @@ jobs: mode: ${{ steps.inputs.outputs.mode }} release_profile: ${{ steps.inputs.outputs.release_profile }} run_release_soak: ${{ steps.inputs.outputs.run_release_soak }} + fail_fast: ${{ steps.inputs.outputs.fail_fast }} run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }} allow_unreleased_changelog: ${{ steps.inputs.outputs.allow_unreleased_changelog }} rerun_group: ${{ steps.inputs.outputs.rerun_group }} @@ -312,6 +318,7 @@ jobs: RELEASE_MODE_INPUT: ${{ inputs.mode }} RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }} RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }} + RELEASE_FAIL_FAST_INPUT: ${{ inputs.fail_fast }} RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }} RELEASE_ALLOW_UNRELEASED_CHANGELOG_INPUT: ${{ inputs.allow_unreleased_changelog }} RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }} @@ -354,6 +361,12 @@ jobs: else run_release_soak=true fi + fail_fast="$(printf '%s' "$RELEASE_FAIL_FAST_INPUT" | tr '[:upper:]' '[:lower:]')" + if [[ "$fail_fast" != "true" && "$fail_fast" != "1" && "$fail_fast" != "yes" ]]; then + fail_fast=false + else + fail_fast=true + fi run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')" if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then run_maturity_scorecard=false @@ -482,6 +495,7 @@ jobs: printf 'mode=%s\n' "$RELEASE_MODE_INPUT" printf 'release_profile=%s\n' "$release_profile" printf 'run_release_soak=%s\n' "$run_release_soak" + printf 'fail_fast=%s\n' "$fail_fast" printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard" printf 'allow_unreleased_changelog=%s\n' "$allow_unreleased_changelog" printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT" @@ -507,6 +521,7 @@ jobs: RELEASE_MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }} RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }} + FAIL_FAST: ${{ steps.inputs.outputs.fail_fast }} RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }} ALLOW_UNRELEASED_CHANGELOG: ${{ steps.inputs.outputs.allow_unreleased_changelog }} RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }} @@ -526,6 +541,7 @@ jobs: echo "- Cross-OS mode: \`${RELEASE_MODE}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Matrix QA fail fast: \`${FAIL_FAST}\`" echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`" echo "- Allow Unreleased changelog packaging: \`${ALLOW_UNRELEASED_CHANGELOG}\`" echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`" @@ -1636,6 +1652,7 @@ jobs: ref: ${{ needs.resolve_target.outputs.revision }} expected_sha: ${{ needs.resolve_target.outputs.revision }} run_matrix: true + fail_fast: ${{ fromJSON(needs.resolve_target.outputs.fail_fast) }} # The dispatched child owns Telegram evidence/status artifacts; this blocking job # carries its exact conclusion into the parent summary without copying secrets or artifacts. diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index a3b6e168902d..a777595f4c14 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -23,6 +23,11 @@ on: required: false default: false type: boolean + fail_fast: + description: Stop the Matrix QA lane after its first failed check or scenario + required: false + default: false + type: boolean run_telegram: description: Run the Telegram live lane required: false @@ -392,17 +397,23 @@ jobs: shell: bash env: OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" + FAIL_FAST: ${{ inputs.fail_fast }} run: | set -euo pipefail output_dir=".artifacts/qa-e2e/matrix-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" mkdir -p "${output_dir}" - pnpm openclaw qa matrix \ + matrix_args=( --repo-root . \ --output-dir "${output_dir}" \ --provider-mode mock-openai \ --fast + ) + if [[ "$FAIL_FAST" == "true" ]] && pnpm openclaw qa matrix --help 2>/dev/null | grep -F -q -- "--fail-fast"; then + matrix_args+=(--fail-fast) + fi + pnpm openclaw qa matrix "${matrix_args[@]}" - name: Upload Matrix QA artifacts id: upload_artifacts diff --git a/docs/ci.md b/docs/ci.md index 792b6ae72914..c4bb814831db 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -344,6 +344,12 @@ intentionally want the broad advisory provider/media matrix. Stable and full release checks always run the exhaustive live/E2E and Docker release-path soak; the beta profile can opt in with `run_release_soak=true`. +`fail_fast` defaults to `false`: the umbrella waits for each dispatched child +workflow and reports its independent failures together. Set `fail_fast=true` +only when cancelling a child after its first failed job is more useful than the +complete failure inventory. In Release Checks, this also enables the Matrix QA +CLI's own first-scenario cancellation. + - `beta` keeps the fastest OpenAI/core release-critical lanes. - `stable` adds the stable provider/backend set. - `full` runs the broad advisory provider/media matrix. diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index fc62d6f520e6..8bd7675b1b7a 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -26,6 +26,9 @@ pnpm ci:full-release \ end-to-end agent turn. The helper infers the `beta` profile from alpha/beta package versions and `stable` otherwise. Pass alternate workflow inputs with `-f key=value`; use `-f release_profile=full` only for the broad advisory sweep. +`fail_fast` defaults to `false`, so dispatched child workflows finish and expose +independent failures together. Pass `-f fail_fast=true` when the shorter +first-failure cancellation path is preferable. The helper creates a temporary `release-ci/*` ref pinned to one trusted `origin/main` workflow SHA, passes the target SHA only as the candidate `ref`, diff --git a/scripts/full-release-validation-at-sha.d.mts b/scripts/full-release-validation-at-sha.d.mts index a5d617b8cb73..4695cb5733e8 100644 --- a/scripts/full-release-validation-at-sha.d.mts +++ b/scripts/full-release-validation-at-sha.d.mts @@ -11,6 +11,7 @@ export function parseArgs(argv: unknown): { release_profile?: string; rerun_group: string; reuse_evidence: string; + fail_fast: string; }; }; export function releaseProfileForTarget( diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index 57105c14adf6..1c0fb68012ed 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -15,6 +15,7 @@ const DEFAULT_INPUTS = { mode: "both", rerun_group: "all", reuse_evidence: "true", + fail_fast: "false", }; function usage() { @@ -26,8 +27,10 @@ watches the parent run, verifies all child workflow head SHAs match the trusted workflow lineage through the release evidence manifest, then deletes the temporary branch by default. Exact-target and changelog-only Release SHA evidence reuse stay enabled; pass -f reuse_evidence=false to force a fresh -run. The release profile defaults to beta for alpha/beta package versions and -stable otherwise; pass -f release_profile=full for the broad advisory sweep.`); +run. Child workflows collect independent failures by default; pass +-f fail_fast=true to cancel each child after its first failed job. The release +profile defaults to beta for alpha/beta package versions and stable otherwise; +pass -f release_profile=full for the broad advisory sweep.`); } function run(command, args, options = {}) { @@ -144,6 +147,9 @@ export function parseArgs(argv) { if (!["true", "false"].includes(args.inputs.reuse_evidence)) { throw new Error("reuse_evidence must be true or false"); } + if (!["true", "false"].includes(args.inputs.fail_fast)) { + throw new Error("fail_fast must be true or false"); + } if ( Object.hasOwn(args.inputs, "allow_unreleased_changelog") && !["true", "false"].includes(args.inputs.allow_unreleased_changelog) diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 50ee351104b9..ba4d9213f23e 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -35,6 +35,7 @@ describe("full-release-validation-at-sha", () => { mode: "linux", provider: "anthropic", reuse_evidence: "true", + fail_fast: "false", }, sha: "abc123", targetRef: "release/2026.7.1", @@ -124,6 +125,8 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["-f", "reuse_evidence=maybe"])).toThrow( "reuse_evidence must be true or false", ); + expect(parseArgs(["-f", "fail_fast=true"]).inputs.fail_fast).toBe("true"); + expect(() => parseArgs(["-f", "fail_fast=maybe"])).toThrow("fail_fast must be true or false"); expect(() => parseArgs(["-f", "release_profile=minimum"])).toThrow( "release_profile must be beta, stable, or full", ); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 115f4a117497..8476196baf20 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2587,6 +2587,7 @@ describe("package artifact reuse", () => { expect(releaseJob.if).toContain('contains(fromJSON(\'["all","qa","qa-live"]\')'); expect(releaseJob.with).toMatchObject({ expected_sha: "${{ needs.resolve_target.outputs.revision }}", + fail_fast: "${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}", run_matrix: true, }); for (const lane of ["mock_parity", "telegram", "discord", "whatsapp", "slack"]) { @@ -2607,6 +2608,7 @@ describe("package artifact reuse", () => { expect(releaseWorkflow).not.toContain("Run QA Lab live Matrix lane"); expect(releaseWorkflow).not.toContain("pnpm openclaw qa matrix"); expect(qaWorkflow).toContain("pnpm openclaw qa matrix"); + expect(qaWorkflow).toContain('if [[ "$FAIL_FAST" == "true" ]]'); expect(qaWorkflow).toContain('trusted_reason="repository-branch"'); expect(qaWorkflow).toContain('"${selected_revision}" != "${EXPECTED_SHA}"'); expect(qaWorkflow).toContain("EXPECTED_SHA: ${{ inputs.expected_sha }}"); @@ -2633,6 +2635,7 @@ describe("package artifact reuse", () => { expect(matrixJob["continue-on-error"]).toBeUndefined(); expect(matrixJob.strategy).toBeUndefined(); expect(workflowStep(matrixJob, "Run Matrix live lane").env).toEqual({ + FAIL_FAST: "${{ inputs.fail_fast }}", OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1", }); expect(releaseTelegramWorkflow).toContain( @@ -3058,6 +3061,7 @@ describe("package artifact reuse", () => { expect(npmTelegramJob.if).not.toContain("inputs.rerun_group == 'all'"); expect(dispatchStep.env).toEqual({ CHILD_WORKFLOW_REF: "${{ github.ref_name }}", + FAIL_FAST: "${{ inputs.fail_fast }}", GH_TOKEN: "${{ github.token }}", PACKAGE_SPEC: "${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}", PARENT_WORKFLOW_SHA: "${{ github.sha }}", diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 4edf2b89fde4..352194074a3f 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -671,12 +671,32 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { "${{ inputs.release_profile != 'beta' && 240 || 60 }}", ); const fullReleaseSource = readFileSync(".github/workflows/full-release-validation.yml", "utf8"); + expect(fullReleaseWorkflow.on.workflow_dispatch.inputs.fail_fast).toEqual({ + description: + "Cancel each child workflow after its first failed job; false collects independent failures to completion", + required: false, + default: false, + type: "boolean", + }); expect( fullReleaseSource.match(/has failed child jobs before the workflow completed/gu)?.length, ).toBeGreaterThanOrEqual(3); + expect(fullReleaseSource.match(/if \[\[ "\$FAIL_FAST" != "true" \]\]; then/gu)?.length).toBe(4); + expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"'); expect(fullReleaseSource).toContain( "npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run.", ); + expect(releaseChecksWorkflow.on.workflow_dispatch.inputs.fail_fast).toEqual({ + description: "Stop the Matrix QA lane after its first failed check or scenario", + required: false, + default: false, + type: "boolean", + }); + expect(releaseChecksWorkflow.jobs.qa_live_release_checks.with.fail_fast).toBe( + "${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}", + ); + const qaLiveSource = readFileSync(".github/workflows/qa-live-transports-convex.yml", "utf8"); + expect(qaLiveSource).toContain('if [[ "$FAIL_FAST" == "true" ]]'); }); it("allows Unreleased notes only for current-tree release checks", () => {