name: QA Profile Evidence run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }} on: workflow_dispatch: inputs: ref: description: OpenClaw branch, tag, or SHA to run required: true default: main type: string expected_sha: description: Optional full SHA that ref must resolve to required: false default: "" type: string qa_profile: description: Taxonomy QA profile id to run (for example release or all) required: true default: all type: string workflow_call: inputs: ref: description: OpenClaw branch, tag, or SHA to run required: true type: string expected_sha: description: Optional full SHA that ref must resolve to required: false default: "" type: string qa_profile: description: Taxonomy QA profile id to run required: true type: string secrets: OPENAI_API_KEY: description: OpenAI API key used by live QA profile scenarios required: true outputs: artifact_name: description: Uploaded QA profile evidence artifact name value: ${{ jobs.run_qa_profile.outputs.artifact_name }} qa_profile: description: Taxonomy QA profile id that produced the evidence value: ${{ jobs.run_qa_profile.outputs.qa_profile }} qa_exit_code: description: Exit code from the QA profile run; non-zero evidence is still uploaded value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }} qa_passed: description: Whether the QA profile command exited successfully value: ${{ jobs.run_qa_profile.outputs.qa_passed }} target_sha: description: Resolved OpenClaw SHA that produced the evidence value: ${{ jobs.run_qa_profile.outputs.target_sha }} trusted_reason: description: Trust reason accepted before the secret-bearing QA job value: ${{ jobs.run_qa_profile.outputs.trusted_reason }} qa_evidence_path: description: Path to qa-evidence.json inside the uploaded artifact value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }} permissions: contents: read concurrency: group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.x" OPENCLAW_BUILD_PRIVATE_QA: "1" OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000" jobs: authorize_actor: name: Authorize workflow actor runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: authorized: ${{ steps.permission.outputs.authorized }} steps: - name: Require maintainer-level repository access id: permission uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | // Reusable workflow jobs inherit the caller event but run as // github-actions[bot]; selected ref validation still gates secrets. if (context.actor === "github-actions[bot]") { core.info("Skipping manual actor permission check for a reusable workflow call."); core.setOutput("authorized", "true"); return; } if (context.eventName !== "workflow_dispatch") { core.info(`Skipping manual actor permission check for ${context.eventName}.`); core.setOutput("authorized", "true"); return; } const allowed = new Set(["admin", "maintain", "write"]); const { owner, repo } = context.repo; const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: context.actor, }); const permission = data.permission; core.info(`Actor ${context.actor} permission: ${permission}`); if (!allowed.has(permission)) { core.notice( `Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`, ); core.setOutput("authorized", "false"); return; } core.setOutput("authorized", "true"); validate_selected_ref: name: Validate selected ref needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: selected_revision: ${{ steps.validate.outputs.selected_revision }} trusted_reason: ${{ steps.validate.outputs.trusted_reason }} steps: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false ref: ${{ inputs.ref }} fetch-depth: 0 - name: Validate selected ref id: validate env: EXPECTED_SHA: ${{ inputs.expected_sha }} INPUT_REF: ${{ inputs.ref }} shell: bash run: | set -euo pipefail selected_revision="$(git rev-parse HEAD)" expected_sha="${EXPECTED_SHA,,}" trusted_reason="" if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2 exit 1 fi if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2 exit 1 fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then trusted_reason="main-ancestor" elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then trusted_reason="release-tag" elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}" release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")" if [[ "$selected_revision" == "$release_branch_sha" ]]; then trusted_reason="release-branch-head" fi fi if [[ -z "$trusted_reason" ]]; then echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2 echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2 exit 1 fi echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT" echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT" { echo "### Target" echo echo "- Requested ref: \`${INPUT_REF}\`" echo "- Resolved SHA: \`$selected_revision\`" echo "- Trust reason: \`$trusted_reason\`" } >> "$GITHUB_STEP_SUMMARY" run_qa_profile: name: Generate QA profile evidence needs: validate_selected_ref runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 permissions: contents: read outputs: artifact_name: ${{ steps.evidence.outputs.artifact_name }} qa_profile: ${{ steps.profile.outputs.profile }} qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }} qa_passed: ${{ steps.evidence.outputs.qa_passed }} target_sha: ${{ steps.evidence.outputs.target_sha }} trusted_reason: ${{ steps.evidence.outputs.trusted_reason }} qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }} environment: qa-live-shared steps: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false ref: ${{ needs.validate_selected_ref.outputs.selected_revision }} fetch-depth: 1 - name: Setup Node environment uses: ./.github/actions/setup-node-env with: node-version: ${{ env.NODE_VERSION }} install-bun: "true" - name: Validate QA profile input id: profile env: QA_PROFILE: ${{ inputs.qa_profile }} shell: bash run: | set -euo pipefail node --import tsx --input-type=module <<'NODE' import fs from "node:fs"; import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts"; const requested = process.env.QA_PROFILE?.trim() ?? ""; if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) { throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`); } const taxonomy = readQaScorecardTaxonomyReport([]); const profile = taxonomy.profiles.find((entry) => entry.id === requested); if (!profile) { const available = taxonomy.profiles.map((entry) => entry.id).join(", "); throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`); } fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`); NODE echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY" - name: Build private QA runtime env: NODE_OPTIONS: --max-old-space-size=8192 run: node scripts/build-all.mjs qaRuntime - name: Ensure Playwright Chromium run: node scripts/ensure-playwright-chromium.mjs - name: Run QA profile id: run_profile env: QA_PROFILE: ${{ steps.profile.outputs.profile }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} shell: bash run: | set -euo pipefail output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT" qa_exit_code=0 pnpm openclaw qa run \ --repo-root . \ --qa-profile "${QA_PROFILE}" \ --output-dir "${output_dir}" || qa_exit_code=$? echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT" - name: Validate QA profile evidence id: evidence if: always() env: ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }} OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }} QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }} QA_PROFILE: ${{ steps.profile.outputs.profile }} REQUESTED_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }} TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }} shell: bash run: | set -euo pipefail node --input-type=module <<'NODE' import fs from "node:fs"; import path from "node:path"; const outputDir = process.env.OUTPUT_DIR; if (!outputDir) { throw new Error("OUTPUT_DIR is required"); } if (!process.env.QA_EXIT_CODE) { throw new Error("QA_EXIT_CODE is required"); } const evidencePath = path.join(outputDir, "qa-evidence.json"); const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8")); if (payload.profile !== process.env.QA_PROFILE) { throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`); } if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) { throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports"); } if (payload.scorecard.categoryReports.length === 0) { throw new Error("QA profile qa-evidence.json scorecard has no category reports"); } const manifest = { artifactName: process.env.ARTIFACT_NAME, generatedAt: new Date().toISOString(), qaProfile: process.env.QA_PROFILE, qaExitCode: Number(process.env.QA_EXIT_CODE), qaPassed: process.env.QA_EXIT_CODE === "0", requestedRef: process.env.REQUESTED_REF, targetSha: process.env.TARGET_SHA, trustedReason: process.env.TRUSTED_REASON, evidenceMode: payload.evidenceMode, qaEvidencePath: "qa-evidence.json", scorecard: { categories: payload.scorecard.categories, features: payload.scorecard.features, categoryReports: payload.scorecard.categoryReports.length, }, }; fs.writeFileSync( path.join(outputDir, "qa-profile-evidence-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, ); NODE echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT" echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT" echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT" if [[ "$QA_EXIT_CODE" == "0" ]]; then echo "qa_passed=true" >> "$GITHUB_OUTPUT" else echo "qa_passed=false" >> "$GITHUB_OUTPUT" echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded." fi echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT" echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT" echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT" { echo "### QA profile evidence" echo echo "- Artifact: \`${ARTIFACT_NAME}\`" echo "- QA profile: \`${QA_PROFILE}\`" echo "- QA exit code: \`${QA_EXIT_CODE}\`" echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`" echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`" } >> "$GITHUB_STEP_SUMMARY" - name: Upload QA profile evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }} path: ${{ steps.run_profile.outputs.output_dir }} retention-days: 30 if-no-files-found: error - name: Fail if QA profile failed if: always() env: QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }} QA_PROFILE: ${{ steps.profile.outputs.profile }} shell: bash run: | set -euo pipefail if [[ -z "${QA_EXIT_CODE:-}" ]]; then echo "QA profile did not report an exit code." >&2 exit 1 fi if [[ "$QA_EXIT_CODE" != "0" ]]; then echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2 exit "$QA_EXIT_CODE" fi