From 8824dcc497d3749d0e887b32a2b2fff4d16e89bc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 02:42:24 -0700 Subject: [PATCH] fix(ci): authenticate release validation fast paths --- .github/workflows/ci.yml | 74 ++++++++- .github/workflows/full-release-validation.yml | 3 +- .github/workflows/openclaw-release-checks.yml | 2 + .../workflows/qa-live-transports-convex.yml | 16 +- docs/ci.md | 4 +- test/scripts/ci-workflow-guards.test.ts | 142 ++++++++++++++++-- .../package-acceptance-workflow.test.ts | 18 +++ 7 files changed, 238 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64d9a5db6a11..e790623d485a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,16 @@ on: required: false default: "" type: string + full_release_validation_run_id: + description: Optional authenticated Full Release Validation parent run ID + required: false + default: "" + type: string + full_release_validation_run_attempt: + description: Optional authenticated Full Release Validation parent run attempt + required: false + default: "" + type: string historical_target_tag: description: Semver release tag authorizing compatibility fallbacks for its exact commit required: false @@ -76,6 +86,7 @@ jobs: # work fan out from a single source of truth. preflight: permissions: + actions: read contents: read if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }} @@ -132,11 +143,70 @@ jobs: run_openclawkit_tests: ${{ steps.manifest.outputs.run_openclawkit_tests }} run_ios_build: ${{ steps.manifest.outputs.run_ios_build }} run_ios_screenshots: ${{ steps.changed_scope.outputs.run_ios_screenshots }} + trusted_frv_first_attempt: ${{ steps.frv_parent.outputs.trusted_first_attempt }} run_android_job: ${{ steps.manifest.outputs.run_android_job }} use_compatible_android_ci: ${{ steps.manifest.outputs.use_compatible_android_ci }} run_protocol_event_coverage: ${{ steps.manifest.outputs.run_protocol_event_coverage }} android_matrix: ${{ steps.manifest.outputs.android_matrix }} steps: + - name: Validate Full Release Validation parent + id: frv_parent + env: + GH_TOKEN: ${{ github.token }} + PARENT_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} + PARENT_RUN_ID: ${{ inputs.full_release_validation_run_id }} + TARGET_REF: ${{ inputs.target_ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + echo "trusted_first_attempt=false" >> "$GITHUB_OUTPUT" + + if [[ -z "$PARENT_RUN_ID" && -z "$PARENT_RUN_ATTEMPT" ]]; then + exit 0 + fi + if [[ ! "$PARENT_RUN_ID" =~ ^[1-9][0-9]*$ || ! "$PARENT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Full Release Validation parent run ID and attempt must be positive integers." >&2 + exit 1 + fi + if [[ ! "$TARGET_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Full Release Validation parent routing requires an exact target SHA." >&2 + exit 1 + fi + + parent_run="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${PARENT_RUN_ID}/attempts/${PARENT_RUN_ATTEMPT}" + )" + expected_title="Full Release Validation ${TARGET_REF}" + if ! jq -e \ + --arg branch "$GITHUB_REF_NAME" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg sha "$WORKFLOW_SHA" \ + --arg title "$expected_title" \ + --argjson attempt "$PARENT_RUN_ATTEMPT" \ + --argjson run_id "$PARENT_RUN_ID" \ + ' + .id == $run_id + and .run_attempt == $attempt + and .event == "workflow_dispatch" + and .path == ".github/workflows/full-release-validation.yml" + and .head_sha == $sha + and .head_branch == $branch + and .repository.full_name == $repository + and .display_title == $title + and .status == "in_progress" + and .conclusion == null + ' <<< "$parent_run" >/dev/null; then + echo "::error::Full Release Validation parent identity did not match this CI target and workflow." >&2 + jq '{id, run_attempt, event, path, head_sha, head_branch, repository: .repository.full_name, display_title, status, conclusion}' \ + <<< "$parent_run" >&2 || true + exit 1 + fi + + if [[ "$PARENT_RUN_ATTEMPT" == "1" && "$GITHUB_RUN_ATTEMPT" == "1" ]]; then + echo "trusted_first_attempt=true" >> "$GITHUB_OUTPUT" + fi + - name: Validate release-gate dispatch if: github.event_name == 'workflow_dispatch' && inputs.release_gate env: @@ -3688,9 +3758,9 @@ jobs: name: "ios-build" needs: [preflight] if: needs.preflight.outputs.run_ios_build == 'true' - # The FRV child is a trusted exact-SHA dispatch with an identity-bound nonce. + # Hosted preflight authenticates the exact parent run before this job routes. # Keep ordinary/manual/release-gate runs and every retry on hosted capacity. - runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || github.run_attempt > 1 && 'macos-26' || (github.event_name == 'workflow_dispatch' && (inputs.release_gate || !startsWith(inputs.dispatch_id, 'full-release-validation-') || !endsWith(inputs.dispatch_id, '-ci'))) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} + runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || github.run_attempt > 1 && 'macos-26' || (github.event_name == 'workflow_dispatch' && (inputs.release_gate || needs.preflight.outputs.trusted_frv_first_attempt != 'true')) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} timeout-minutes: 150 env: HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index dc1a82b8494d..ac5d0b063467 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -1,4 +1,5 @@ name: Full Release Validation +run-name: Full Release Validation ${{ inputs.expected_sha || inputs.ref }} on: workflow_dispatch: @@ -991,7 +992,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" dispatch_run_name="CI ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") + args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id" -f full_release_validation_run_id="$GITHUB_RUN_ID" -f full_release_validation_run_attempt="$GITHUB_RUN_ATTEMPT") if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then args+=(-f historical_target_tag="$TARGET_REF") elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 2fb796db70ae..b80380edbc1b 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -2090,6 +2090,7 @@ jobs: with: ref: ${{ needs.resolve_target.outputs.revision }} expected_sha: ${{ needs.resolve_target.outputs.revision }} + lock_scope: matrix run_matrix: true fail_fast: ${{ fromJSON(needs.resolve_target.outputs.fail_fast) }} secrets: @@ -2108,6 +2109,7 @@ jobs: with: ref: ${{ needs.resolve_target.outputs.revision }} expected_sha: ${{ needs.resolve_target.outputs.revision }} + lock_scope: buzz run_buzz: true buzz_scenario: channel-canary,channel-mention-gating secrets: diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index 278ac7848a21..646b4194f7a4 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -13,6 +13,11 @@ on: description: Exact resolved SHA expected from the trusted caller required: true type: string + lock_scope: + description: Concurrency scope for a trusted single-lane reusable call + required: false + default: all + type: string run_mock_parity: description: Run the mock parity lane required: false @@ -118,9 +123,9 @@ permissions: pull-requests: read concurrency: - # Release checks call Matrix and Buzz independently. Give those single-lane - # calls separate locks while retaining the shared lock for aggregate runs. - group: qa-lab-${{ github.event_name == 'workflow_call' && inputs.run_buzz && !inputs.run_matrix && 'buzz' || github.event_name == 'workflow_call' && inputs.run_matrix && !inputs.run_buzz && 'matrix' || 'all' }}-${{ github.event_name != 'schedule' && inputs.ref || github.sha }} + # Trusted reusable callers name their single-lane scope explicitly. Manual + # and scheduled aggregate runs retain the shared all-lanes lock. + group: qa-lab-${{ inputs.lock_scope || 'all' }}-${{ github.event_name != 'schedule' && inputs.ref || github.sha }} cancel-in-progress: false queue: max @@ -438,6 +443,10 @@ jobs: if: inputs.expected_sha == '' || inputs.run_matrix runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 90 + concurrency: + group: qa-live-matrix-${{ needs.validate_selected_ref.outputs.selected_revision }} + cancel-in-progress: false + queue: max environment: qa-live-shared steps: - name: Checkout selected ref @@ -502,6 +511,7 @@ jobs: concurrency: group: qa-live-buzz-shared cancel-in-progress: false + queue: max environment: qa-live-shared steps: - name: Checkout selected ref diff --git a/docs/ci.md b/docs/ci.md index 9382e8c47740..b9f931e56241 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -182,7 +182,7 @@ Barnacle treats bug-labeled issues as verification candidates rather than inacti Ordinary manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI/native app i18n. Node 22 compatibility runs in Full Release Validation and manual dispatches only; push and pull request CI skip it. The exact-head `release_gate` fallback instead keeps the pull request's macOS and iOS scope, including conservative release screenshot capture for screenshot-pipeline owners. Automatic source PRs verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled. -The exact Full Release Validation child routes `ios-build` to Blacksmith on its first attempt. Ordinary manual dispatches, exact-head `release_gate` fallbacks, non-canonical repositories, the `github` backend override, and every rerun stay on GitHub-hosted macOS. +An exact-SHA Full Release Validation child routes `ios-build` to Blacksmith only when hosted preflight authenticates the live parent run ID, first attempt, workflow SHA, workflow ref, and target SHA through the Actions API. Ordinary manual dispatches, exact-head `release_gate` fallbacks, non-canonical repositories, the `github` backend override, and every parent or child rerun stay on GitHub-hosted macOS. PR baseline ratchets derive their comparison state from the checked-out synthetic merge tree and verify its head parent against the event head. The max-lines entry chains the environment-variable budget with the same fork-point ref before the assertion-safety check, so production source growth cannot first surface on `main`. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; ratchet baselines are compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated. @@ -274,7 +274,7 @@ concurrent repositories, retries, and burst overlap. The changed-target PR plan reduces the common Node test burst from 29 Blacksmith registrations to one. Broad-risk all-Blacksmith PRs keep the 32-registration metadata-complete compact fallback; canonical all-Blacksmith pushes use 24 nondist compact registrations. Hybrid currently uses 53 nondist fallback rows and 46 nondist push rows. The `github` and `hybrid` planner profiles remain capped at 96 compact rows; `github` rows are hosted, while hybrid rows consume Blacksmith registrations on attempt 1 and move to hosted capacity on retries. Even the 96-row cap plus roughly 25 other Blacksmith lanes is about 121 registrations per full run, or 484 for four admitted runs in a five-minute window, far below the 6,000-registration operating target. -Canonical-repo CI keeps Blacksmith as the default runner path for pushes and first-attempt same-repo pull-request runs when the backend is unset or `blacksmith`. Hybrid keeps the heavy set plus the named critical-path plateau lanes on Blacksmith for attempt 1; other light lanes and every rerun Blacksmith lane use GitHub-hosted capacity. Pull-request retries of both UI E2E jobs use GitHub-hosted Ubuntu in every mode; push retries remain on their normal backend unless hybrid fallback applies. All `workflow_dispatch` runs, including `release_gate`, and non-canonical repository runs use GitHub-hosted runners. The [`github` backend](#runner-backend-modes) provides a manual repository-wide fallback; canonical runs do not probe Blacksmith queue health or mutate the variable automatically. +Canonical-repo CI keeps Blacksmith as the default runner path for pushes and first-attempt same-repo pull-request runs when the backend is unset or `blacksmith`. Hybrid keeps the heavy set plus the named critical-path plateau lanes on Blacksmith for attempt 1; other light lanes and every rerun Blacksmith lane use GitHub-hosted capacity. Pull-request retries of both UI E2E jobs use GitHub-hosted Ubuntu in every mode; push retries remain on their normal backend unless hybrid fallback applies. Ordinary `workflow_dispatch` runs, including `release_gate`, and non-canonical repository runs use GitHub-hosted runners. The authenticated first-attempt exact-SHA Full Release Validation iOS child is the sole manual-dispatch exception. The [`github` backend](#runner-backend-modes) provides a manual repository-wide fallback; canonical runs do not probe Blacksmith queue health or mutate the variable automatically. ## Surface ratchets diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index d4e59eefe25b..f281ef674d8a 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -93,8 +93,8 @@ function evaluateWorkflowExpression( repository: string; runnerBackend?: "" | "blacksmith" | "github" | "hybrid"; runAttempt: number; + trustedFrvFirstAttempt?: boolean; inputs?: { - dispatchId?: string; releaseGate?: boolean; }; }, @@ -116,7 +116,6 @@ function evaluateWorkflowExpression( Array.isArray(haystack) ? haystack.includes(needle) : String(haystack).includes(String(needle)), - endsWith: (value: unknown, suffix: unknown) => String(value).endsWith(String(suffix)), fromJSON: (value: string) => JSON.parse(value) as unknown, github: { event_name: context.eventName, @@ -133,11 +132,16 @@ function evaluateWorkflowExpression( : {}, }, inputs: { - dispatch_id: context.inputs?.dispatchId ?? "", release_gate: context.inputs?.releaseGate ?? false, }, matrix: context.matrix ?? {}, - startsWith: (value: unknown, prefix: unknown) => String(value).startsWith(String(prefix)), + needs: { + preflight: { + outputs: { + trusted_frv_first_attempt: context.trustedFrvFirstAttempt ? "true" : "false", + }, + }, + }, vars: { OPENCLAW_CI_RUNNER_BACKEND: context.runnerBackend ?? "", }, @@ -1422,13 +1426,19 @@ describe("ci workflow guards", () => { expect(workflow.concurrency).toEqual({ group: - "qa-lab-${{ github.event_name == 'workflow_call' && inputs.run_buzz && !inputs.run_matrix && 'buzz' || github.event_name == 'workflow_call' && inputs.run_matrix && !inputs.run_buzz && 'matrix' || 'all' }}-${{ github.event_name != 'schedule' && inputs.ref || github.sha }}", + "qa-lab-${{ inputs.lock_scope || 'all' }}-${{ github.event_name != 'schedule' && inputs.ref || github.sha }}", + "cancel-in-progress": false, + queue: "max", + }); + expect(workflow.jobs.run_live_matrix.concurrency).toEqual({ + group: "qa-live-matrix-${{ needs.validate_selected_ref.outputs.selected_revision }}", "cancel-in-progress": false, queue: "max", }); expect(workflow.jobs.run_live_buzz.concurrency).toEqual({ group: "qa-live-buzz-shared", "cancel-in-progress": false, + queue: "max", }); }); @@ -1728,7 +1738,7 @@ NODE expect(changedScopeStep.run).toContain( 'node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"', ); - expect(workflow.jobs.preflight.permissions).toEqual({ contents: "read" }); + expect(workflow.jobs.preflight.permissions).toEqual({ actions: "read", contents: "read" }); expect(workflow.jobs.preflight.outputs.run_ios_screenshots).toBe( "${{ steps.changed_scope.outputs.run_ios_screenshots }}", ); @@ -1761,17 +1771,123 @@ NODE } }); + it("authenticates the exact Full Release Validation parent before fast routing", () => { + const workflow = readCiWorkflow(); + const validateStep = expectDefined( + workflow.jobs.preflight.steps.find( + (step: WorkflowStep) => step.name === "Validate Full Release Validation parent", + ), + "Full Release Validation parent validation", + ); + const targetSha = "a".repeat(40); + const workflowSha = "b".repeat(40); + const baseRun = { + conclusion: null, + display_title: `Full Release Validation ${targetSha}`, + event: "workflow_dispatch", + head_branch: "main", + head_sha: workflowSha, + id: 32441524595, + path: ".github/workflows/full-release-validation.yml", + repository: { full_name: "openclaw/openclaw" }, + run_attempt: 1, + status: "in_progress", + }; + const runCase = (options?: { + childAttempt?: number; + parentAttempt?: number; + parentRun?: Record; + provideParent?: boolean; + }) => { + const root = tempDirs.make("openclaw-frv-parent-"); + const binDir = path.join(root, "bin"); + const callsPath = path.join(root, "gh-calls"); + const outputPath = path.join(root, "github-output"); + mkdirSync(binDir); + writeExecutable(path.join(binDir, "gh"), [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf "%s\\n" "$*" >> "$GH_CALLS"', + 'printf "%s\\n" "$MOCK_GH_RESPONSE"', + ]); + const provideParent = options?.provideParent ?? true; + const parentAttempt = options?.parentAttempt ?? 1; + const parentRun = options?.parentRun ?? { ...baseRun, run_attempt: parentAttempt }; + const result = runWorkflowShellScript(expectDefined(validateStep.run, "validation script"), { + cwd: root, + env: { + ...process.env, + GH_CALLS: callsPath, + GITHUB_OUTPUT: outputPath, + GITHUB_REF_NAME: "main", + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_RUN_ATTEMPT: String(options?.childAttempt ?? 1), + MOCK_GH_RESPONSE: JSON.stringify(parentRun), + PARENT_RUN_ATTEMPT: provideParent ? String(parentAttempt) : "", + PARENT_RUN_ID: provideParent ? String(baseRun.id) : "", + PATH: `${binDir}:${process.env.PATH ?? ""}`, + TARGET_REF: targetSha, + WORKFLOW_SHA: workflowSha, + }, + }); + return { + calls: existsSync(callsPath) ? readFileSync(callsPath, "utf8") : "", + outputs: readWorkflowOutputs(outputPath), + result, + }; + }; + + expect(workflow.on.workflow_dispatch.inputs.full_release_validation_run_id).toMatchObject({ + default: "", + required: false, + type: "string", + }); + expect(workflow.on.workflow_dispatch.inputs.full_release_validation_run_attempt).toMatchObject({ + default: "", + required: false, + type: "string", + }); + expect(workflow.jobs.preflight.outputs.trusted_frv_first_attempt).toBe( + "${{ steps.frv_parent.outputs.trusted_first_attempt }}", + ); + + const trusted = runCase(); + expect(trusted.result.status, trusted.result.stderr).toBe(0); + expect(trusted.outputs.trusted_first_attempt).toBe("true"); + expect(trusted.calls).toContain("repos/openclaw/openclaw/actions/runs/32441524595/attempts/1"); + + const generic = runCase({ provideParent: false }); + expect(generic.result.status, generic.result.stderr).toBe(0); + expect(generic.outputs.trusted_first_attempt).toBe("false"); + expect(generic.calls).toBe(""); + + for (const rerun of [runCase({ childAttempt: 2 }), runCase({ parentAttempt: 2 })]) { + expect(rerun.result.status, rerun.result.stderr).toBe(0); + expect(rerun.outputs.trusted_first_attempt).toBe("false"); + } + + for (const parentRun of [ + { ...baseRun, id: baseRun.id + 1 }, + { ...baseRun, run_attempt: 2 }, + { ...baseRun, head_sha: "c".repeat(40) }, + { ...baseRun, display_title: "Full Release Validation main" }, + { ...baseRun, path: ".github/workflows/ci.yml" }, + { ...baseRun, status: "completed", conclusion: "success" }, + ]) { + const rejected = runCase({ parentRun }); + expect(rejected.result.status, JSON.stringify(parentRun)).toBe(1); + expect(rejected.outputs.trusted_first_attempt).toBe("false"); + } + }); + it("routes only trusted first-attempt Full Release Validation iOS to Blacksmith", () => { const runsOn = readCiWorkflow().jobs["ios-build"]["runs-on"]; - const frvInputs = { - dispatchId: "full-release-validation-32441524595-1-ci", - releaseGate: false, - }; const canonicalDispatch = { eventName: "workflow_dispatch", - inputs: frvInputs, + inputs: { releaseGate: false }, repository: "openclaw/openclaw", runAttempt: 1, + trustedFrvFirstAttempt: true, } as const; expect(evaluateWorkflowExpression(runsOn, canonicalDispatch)).toBe( @@ -1792,13 +1908,13 @@ NODE expect( evaluateWorkflowExpression(runsOn, { ...canonicalDispatch, - inputs: { dispatchId: "", releaseGate: false }, + trustedFrvFirstAttempt: false, }), ).toBe("macos-26"); expect( evaluateWorkflowExpression(runsOn, { ...canonicalDispatch, - inputs: { ...frvInputs, releaseGate: true }, + inputs: { releaseGate: true }, }), ).toBe("macos-26"); expect( diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 33c84509832a..3d88921cb4bf 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2771,6 +2771,7 @@ describe("package acceptance workflow", () => { it("requires full release child workflows to run at the parent workflow SHA", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); + const parsedWorkflow = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW); const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); const performanceJob = workflowStep( workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance"), @@ -2778,6 +2779,9 @@ describe("package acceptance workflow", () => { ).run; expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}"); + expect(parsedWorkflow["run-name"]).toBe( + "Full Release Validation ${{ inputs.expected_sha || inputs.ref }}", + ); expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}"); expect(workflow).toContain("PARENT_WORKFLOW_SHA: ${{ github.sha }}"); expect(workflow).toContain("release_package_spec:"); @@ -2807,6 +2811,8 @@ describe("package acceptance workflow", () => { ); expect(workflow).toContain('if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then'); expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1'); + expect(workflow).toContain('-f full_release_validation_run_id="$GITHUB_RUN_ID"'); + expect(workflow).toContain('-f full_release_validation_run_attempt="$GITHUB_RUN_ATTEMPT"'); expect(performanceJob).toContain( 'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', ); @@ -4888,6 +4894,7 @@ describe("package artifact reuse", () => { expect(releaseJob.with).toMatchObject({ expected_sha: "${{ needs.resolve_target.outputs.revision }}", fail_fast: "${{ fromJSON(needs.resolve_target.outputs.fail_fast) }}", + lock_scope: "matrix", run_matrix: true, }); for (const lane of ["mock_parity", "buzz", "telegram", "discord", "whatsapp", "slack"]) { @@ -4923,6 +4930,11 @@ describe("package artifact reuse", () => { expect(qaWorkflow).toContain('if [[ -n "${EXPECTED_SHA}" ]]; then'); const matrixJob = workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_matrix"); expect(matrixJob["timeout-minutes"]).toBe(90); + expect(matrixJob.concurrency).toEqual({ + group: "qa-live-matrix-${{ needs.validate_selected_ref.outputs.selected_revision }}", + "cancel-in-progress": false, + queue: "max", + }); expect(workflowStep(matrixJob, "Run Matrix live lane").run).toContain( "--provider-mode mock-openai", ); @@ -4962,10 +4974,16 @@ describe("package artifact reuse", () => { expect(releaseJob.with).toMatchObject({ buzz_scenario: "channel-canary,channel-mention-gating", expected_sha: "${{ needs.resolve_target.outputs.revision }}", + lock_scope: "buzz", run_buzz: true, }); const buzzJob = workflowJob(QA_LIVE_TRANSPORTS_WORKFLOW, "run_live_buzz"); expect(buzzJob.if).toBe("inputs.run_buzz"); + expect(buzzJob.concurrency).toEqual({ + group: "qa-live-buzz-shared", + "cancel-in-progress": false, + queue: "max", + }); const resolveBuzz = workflowStep(buzzJob, "Resolve Buzz QA runner"); expect(resolveBuzz.run).toContain('runner?.commandName === "buzz"'); expect(resolveBuzz.run).toContain("selected ref does not declare the Buzz QA runner");