From d68f7ec77c48714502cf899b1cfdddf85c38b3ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 02:45:33 -0700 Subject: [PATCH] fix(ci): fall back when FRV routing auth is unavailable --- .github/workflows/ci.yml | 41 +++++++++++++++++-------- docs/ci.md | 2 +- test/scripts/ci-workflow-guards.test.ts | 27 +++++++++++++--- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e790623d485a..8821e3c9ffad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,31 +152,44 @@ jobs: - name: Validate Full Release Validation parent id: frv_parent env: + CI_RUNNER_BACKEND: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND }} GH_TOKEN: ${{ github.token }} PARENT_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} PARENT_RUN_ID: ${{ inputs.full_release_validation_run_id }} + RELEASE_GATE: ${{ inputs.release_gate }} TARGET_REF: ${{ inputs.target_ref }} WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail echo "trusted_first_attempt=false" >> "$GITHUB_OUTPUT" + # Parent authentication only unlocks the attempt-one fast route. Any + # hosted-policy case must remain independent of the parent API. + if [[ "$GITHUB_RUN_ATTEMPT" != "1" || "$CI_RUNNER_BACKEND" == "github" || "$RELEASE_GATE" == "true" ]]; then + exit 0 + fi 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 + echo "::warning::Full Release Validation parent identity is malformed; routing iOS to GitHub-hosted macOS." >&2 + exit 0 + fi + if [[ "$PARENT_RUN_ATTEMPT" != "1" ]]; then + exit 0 fi if [[ ! "$TARGET_REF" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::Full Release Validation parent routing requires an exact target SHA." >&2 - exit 1 + echo "::warning::Full Release Validation parent routing requires an exact target SHA; routing iOS to GitHub-hosted macOS." >&2 + exit 0 fi - parent_run="$( + if ! parent_run="$( gh api \ - "repos/${GITHUB_REPOSITORY}/actions/runs/${PARENT_RUN_ID}/attempts/${PARENT_RUN_ATTEMPT}" - )" + "repos/${GITHUB_REPOSITORY}/actions/runs/${PARENT_RUN_ID}/attempts/${PARENT_RUN_ATTEMPT}" 2>&1 + )"; then + echo "::warning::Could not authenticate the Full Release Validation parent through the Actions API; routing iOS to GitHub-hosted macOS." >&2 + exit 0 + fi expected_title="Full Release Validation ${TARGET_REF}" if ! jq -e \ --arg branch "$GITHUB_REF_NAME" \ @@ -194,19 +207,21 @@ jobs: 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 + echo "::warning::Full Release Validation parent identity did not match this CI target and workflow; routing iOS to GitHub-hosted macOS." >&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 + exit 0 fi - if [[ "$PARENT_RUN_ATTEMPT" == "1" && "$GITHUB_RUN_ATTEMPT" == "1" ]]; then - echo "trusted_first_attempt=true" >> "$GITHUB_OUTPUT" + if ! jq -e '.status == "in_progress" and .conclusion == null' \ + <<< "$parent_run" >/dev/null; then + echo "::warning::Full Release Validation parent is no longer active; routing iOS to GitHub-hosted macOS." >&2 + exit 0 fi + echo "trusted_first_attempt=true" >> "$GITHUB_OUTPUT" + - name: Validate release-gate dispatch if: github.event_name == 'workflow_dispatch' && inputs.release_gate env: diff --git a/docs/ci.md b/docs/ci.md index b9f931e56241..28a8038f42e0 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. -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. +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. Parent API errors, identity mismatches, and parents that finish before child preflight also fall back to hosted macOS without failing CI. 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. diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index f281ef674d8a..4dc31912d090 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -1795,9 +1795,12 @@ NODE }; const runCase = (options?: { childAttempt?: number; + ghStatus?: number; parentAttempt?: number; parentRun?: Record; provideParent?: boolean; + releaseGate?: boolean; + runnerBackend?: string; }) => { const root = tempDirs.make("openclaw-frv-parent-"); const binDir = path.join(root, "bin"); @@ -1809,6 +1812,7 @@ NODE "set -euo pipefail", 'printf "%s\\n" "$*" >> "$GH_CALLS"', 'printf "%s\\n" "$MOCK_GH_RESPONSE"', + 'exit "${MOCK_GH_STATUS:-0}"', ]); const provideParent = options?.provideParent ?? true; const parentAttempt = options?.parentAttempt ?? 1; @@ -1817,15 +1821,18 @@ NODE cwd: root, env: { ...process.env, + CI_RUNNER_BACKEND: options?.runnerBackend ?? "", 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), + MOCK_GH_STATUS: String(options?.ghStatus ?? 0), PARENT_RUN_ATTEMPT: provideParent ? String(parentAttempt) : "", PARENT_RUN_ID: provideParent ? String(baseRun.id) : "", PATH: `${binDir}:${process.env.PATH ?? ""}`, + RELEASE_GATE: options?.releaseGate ? "true" : "false", TARGET_REF: targetSha, WORKFLOW_SHA: workflowSha, }, @@ -1861,9 +1868,15 @@ NODE 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 hostedFallback of [ + runCase({ childAttempt: 2 }), + runCase({ parentAttempt: 2 }), + runCase({ releaseGate: true }), + runCase({ runnerBackend: "github" }), + ]) { + expect(hostedFallback.result.status, hostedFallback.result.stderr).toBe(0); + expect(hostedFallback.outputs.trusted_first_attempt).toBe("false"); + expect(hostedFallback.calls).toBe(""); } for (const parentRun of [ @@ -1875,9 +1888,15 @@ NODE { ...baseRun, status: "completed", conclusion: "success" }, ]) { const rejected = runCase({ parentRun }); - expect(rejected.result.status, JSON.stringify(parentRun)).toBe(1); + expect(rejected.result.status, JSON.stringify(parentRun)).toBe(0); expect(rejected.outputs.trusted_first_attempt).toBe("false"); + expect(rejected.result.stderr).toContain("::warning::"); } + + const apiFailure = runCase({ ghStatus: 1 }); + expect(apiFailure.result.status, apiFailure.result.stderr).toBe(0); + expect(apiFailure.outputs.trusted_first_attempt).toBe("false"); + expect(apiFailure.result.stderr).toContain("::warning::"); }); it("routes only trusted first-attempt Full Release Validation iOS to Blacksmith", () => {