diff --git a/.github/actions/mantis-validate-trusted-ref/action.yml b/.github/actions/mantis-validate-trusted-ref/action.yml new file mode 100644 index 000000000000..9e49cb2fba44 --- /dev/null +++ b/.github/actions/mantis-validate-trusted-ref/action.yml @@ -0,0 +1,75 @@ +name: Validate trusted Mantis ref +description: Resolve Mantis refs and require trusted repository provenance + +inputs: + candidate-ref: + description: Candidate ref, tag, or SHA to validate + required: true + baseline-ref: + description: Optional baseline ref, tag, or SHA to validate + required: false + default: "" + +outputs: + candidate-revision: + description: Resolved candidate commit SHA + value: ${{ steps.validate.outputs.candidate_revision }} + baseline-revision: + description: Resolved baseline commit SHA, or empty when no baseline was supplied + value: ${{ steps.validate.outputs.baseline_revision }} + +runs: + using: composite + steps: + - name: Validate refs are trusted + id: validate + env: + BASELINE_REF: ${{ inputs.baseline-ref }} + CANDIDATE_REF: ${{ inputs.candidate-ref }} + shell: bash + run: | + set -euo pipefail + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + validate_ref() { + local label="$1" + local input_ref="$2" + local revision="" + local reason="" + + revision="$(git rev-parse "${input_ref}^{commit}")" + if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then + reason="main-ancestor" + elif git tag --points-at "$revision" | grep -Eq '^v'; then + reason="release-tag" + else + local pr_head_count + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + reason="open-pr-head" + fi + fi + + if [[ -z "$reason" ]]; then + echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 + exit 1 + fi + + echo "${label}_revision=${revision}" >> "$GITHUB_OUTPUT" + { + echo "${label}: \`${input_ref}\`" + echo "${label} SHA: \`${revision}\`" + echo "${label} trust reason: \`${reason}\`" + } >> "$GITHUB_STEP_SUMMARY" + } + + if [[ -n "$BASELINE_REF" ]]; then + validate_ref baseline "$BASELINE_REF" + fi + validate_ref candidate "$CANDIDATE_REF" diff --git a/.github/workflows/mantis-clear-reaction.yml b/.github/workflows/mantis-clear-reaction.yml new file mode 100644 index 000000000000..191f12fd3225 --- /dev/null +++ b/.github/workflows/mantis-clear-reaction.yml @@ -0,0 +1,56 @@ +name: Mantis Clear Reaction + +on: + workflow_call: + inputs: + comment-id: + description: Issue comment id that owns the Mantis reaction + required: true + type: string + reaction-id: + description: Exact Mantis eyes reaction id to remove + required: true + type: string + secrets: + MANTIS_GITHUB_APP_ID: + required: true + MANTIS_GITHUB_APP_PRIVATE_KEY: + required: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + clear: + name: Clear Mantis command reaction + runs-on: ubuntu-24.04 + permissions: {} + steps: + - name: Create Mantis cleanup GitHub App token + id: mantis_reaction_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} + private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-issues: write + + - name: Remove Mantis eyes reaction + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + COMMENT_ID: ${{ inputs.comment-id }} + REACTION_ID: ${{ inputs.reaction-id }} + with: + github-token: ${{ steps.mantis_reaction_token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const commentId = Number(process.env.COMMENT_ID); + const reactionId = Number(process.env.REACTION_ID); + await github.rest.reactions.deleteForIssueComment({ + owner, + repo, + comment_id: commentId, + reaction_id: reactionId, + }); + core.info(`Removed Mantis eyes reaction ${reactionId}.`); diff --git a/.github/workflows/mantis-discord-status-reactions.yml b/.github/workflows/mantis-discord-status-reactions.yml index 860f63ef1999..9d5855032054 100644 --- a/.github/workflows/mantis-discord-status-reactions.yml +++ b/.github/workflows/mantis-discord-status-reactions.yml @@ -80,117 +80,15 @@ jobs: name: Resolve Mantis request needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} - candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} - pr_number: ${{ steps.resolve.outputs.pr_number }} - reaction_id: ${{ steps.add_reaction.outputs.reaction_id }} - request_source: ${{ steps.resolve.outputs.request_source }} - should_run: ${{ steps.resolve.outputs.should_run }} - steps: - - name: Resolve refs and target PR - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593"; - const eventName = context.eventName; - - function setOutput(name, value) { - core.setOutput(name, value ?? ""); - core.info(`${name}=${value ?? ""}`); - } - - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - setOutput("should_run", "true"); - setOutput("baseline_ref", inputs.baseline_ref || defaultBaseline); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const requested = - (normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) && - normalized.includes("discord") && - normalized.includes("status") && - normalized.includes("reaction"); - if (!requested) { - core.notice("Comment mentioned Mantis but did not request the Discord status-reactions scenario."); - setOutput("should_run", "false"); - setOutput("baseline_ref", ""); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("request_source", "unsupported_issue_comment"); - return; - } - - const { owner, repo } = context.repo; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue.number, - }); - - const baselineMatch = body.match(/(?:baseline|base)[\s:=]+([^\s`]+)/i); - const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); - const baseline = baselineMatch?.[1] ?? defaultBaseline; - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : pr.head.sha; - - setOutput("should_run", "true"); - setOutput("baseline_ref", baseline); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); - setOutput("request_source", "issue_comment"); - - - name: Create Mantis reaction GitHub App token - id: mantis_reaction_token - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' }} - continue-on-error: true - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Add Mantis eyes reaction - id: add_reaction - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - await github.rest.reactions - .createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }) - .then(({ data: reaction }) => core.setOutput("reaction_id", String(reaction.id))) - .catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + uses: ./.github/workflows/mantis-resolve-request.yml + with: + request-pattern: '(?=[\s\S]*discord)(?=[\s\S]*status)(?=[\s\S]*reaction)' + skip-notice: Comment mentioned Mantis but did not request the Discord status-reactions scenario. + dispatch-params: ${{ github.event_name == 'workflow_dispatch' && toJSON(inputs) || '{"baseline_ref":"0bf06e953fdda290799fc9fb9244a8f67fdae593"}' }} + runner: blacksmith-8vcpu-ubuntu-2404 + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} validate_refs: name: Validate selected refs @@ -198,8 +96,8 @@ jobs: if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: - baseline_revision: ${{ steps.validate.outputs.baseline_revision }} - candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + baseline_revision: ${{ steps.validate.outputs['baseline-revision'] }} + candidate_revision: ${{ steps.validate.outputs['candidate-revision'] }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -211,53 +109,10 @@ jobs: id: validate env: GH_TOKEN: ${{ github.token }} - BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }} - CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} - shell: bash - run: | - set -euo pipefail - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - - validate_ref() { - local label="$1" - local input_ref="$2" - local revision="" - local reason="" - - revision="$(git rev-parse "${input_ref}^{commit}")" - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - local pr_head_count - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 - exit 1 - fi - - echo "${label}_revision=${revision}" >> "$GITHUB_OUTPUT" - { - echo "${label}: \`${input_ref}\`" - echo "${label} SHA: \`${revision}\`" - echo "${label} trust reason: \`${reason}\`" - } >> "$GITHUB_STEP_SUMMARY" - } - - validate_ref baseline "$BASELINE_REF" - validate_ref candidate "$CANDIDATE_REF" + uses: ./.github/actions/mantis-validate-trusted-ref + with: + baseline-ref: ${{ fromJSON(needs.resolve_request.outputs.params).baseline_ref }} + candidate-ref: ${{ fromJSON(needs.resolve_request.outputs.params).candidate_ref }} run_status_reactions: name: Run Discord status reaction before/after @@ -607,32 +462,11 @@ jobs: name: Clear Mantis command reaction needs: [resolve_request, validate_refs, run_status_reactions] if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' && needs.resolve_request.outputs.reaction_id != '' }} - runs-on: ubuntu-24.04 permissions: {} - steps: - - name: Create Mantis cleanup GitHub App token - id: mantis_reaction_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Remove Mantis eyes reaction - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - REACTION_ID: ${{ needs.resolve_request.outputs.reaction_id }} - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - const reactionId = Number(process.env.REACTION_ID); - await github.rest.reactions.deleteForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - reaction_id: reactionId, - }); - core.info(`Removed Mantis eyes reaction ${reactionId}.`); + uses: ./.github/workflows/mantis-clear-reaction.yml + with: + comment-id: ${{ format('{0}', github.event.comment.id) }} + reaction-id: ${{ needs.resolve_request.outputs.reaction_id }} + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/mantis-discord-thread-attachment.yml b/.github/workflows/mantis-discord-thread-attachment.yml index aa49de79c812..7fd9450f26d9 100644 --- a/.github/workflows/mantis-discord-thread-attachment.yml +++ b/.github/workflows/mantis-discord-thread-attachment.yml @@ -80,116 +80,16 @@ jobs: name: Resolve Mantis request needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} - candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} - pr_number: ${{ steps.resolve.outputs.pr_number }} - reaction_id: ${{ steps.add_reaction.outputs.reaction_id }} - request_source: ${{ steps.resolve.outputs.request_source }} - should_run: ${{ steps.resolve.outputs.should_run }} - steps: - - name: Resolve refs and target PR - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const defaultBaseline = "synthetic-reverted-thread-filepath-fix"; - const eventName = context.eventName; - - function setOutput(name, value) { - core.setOutput(name, value ?? ""); - core.info(`${name}=${value ?? ""}`); - } - - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - setOutput("should_run", "true"); - setOutput("baseline_ref", inputs.baseline_ref || defaultBaseline); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const requested = - (normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) && - normalized.includes("discord") && - normalized.includes("thread") && - (normalized.includes("attachment") || - normalized.includes("filepath") || - normalized.includes("file path")); - if (!requested) { - core.notice("Comment mentioned Mantis but did not request the Discord thread attachment scenario."); - setOutput("should_run", "false"); - setOutput("baseline_ref", ""); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("request_source", "unsupported_issue_comment"); - return; - } - - const { owner, repo } = context.repo; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue.number, - }); - const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : pr.head.sha; - - setOutput("should_run", "true"); - setOutput("baseline_ref", defaultBaseline); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); - setOutput("request_source", "issue_comment"); - - - name: Create Mantis reaction GitHub App token - id: mantis_reaction_token - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' }} - continue-on-error: true - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Add Mantis eyes reaction - id: add_reaction - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - await github.rest.reactions - .createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }) - .then(({ data: reaction }) => core.setOutput("reaction_id", String(reaction.id))) - .catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + uses: ./.github/workflows/mantis-resolve-request.yml + with: + request-pattern: '(?=[\s\S]*discord)(?=[\s\S]*thread)(?=[\s\S]*(?:attachment|filepath|file path))' + skip-notice: Comment mentioned Mantis but did not request the Discord thread attachment scenario. + dispatch-params: >- + ${{ github.event_name == 'workflow_dispatch' && toJSON(inputs) || '{"baseline_ref":"synthetic-reverted-thread-filepath-fix"}' }} + runner: blacksmith-8vcpu-ubuntu-2404 + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} validate_candidate: name: Validate selected candidate @@ -197,7 +97,7 @@ jobs: if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: - candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_revision: ${{ steps.validate.outputs.candidate-revision }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -207,44 +107,11 @@ jobs: - name: Validate candidate ref is trusted id: validate + uses: ./.github/actions/mantis-validate-trusted-ref env: GH_TOKEN: ${{ github.token }} - CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} - shell: bash - run: | - set -euo pipefail - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - - revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" - reason="" - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 - exit 1 - fi - - echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" - { - echo "Candidate: \`${CANDIDATE_REF}\`" - echo "Candidate SHA: \`${revision}\`" - echo "Candidate trust reason: \`${reason}\`" - } >> "$GITHUB_STEP_SUMMARY" + with: + candidate-ref: ${{ needs.resolve_request.outputs.candidate_ref }} run_thread_attachment: name: Run Discord thread attachment before/after @@ -360,7 +227,7 @@ jobs: CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }} CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }} CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }} - BASELINE_LABEL: ${{ needs.resolve_request.outputs.baseline_ref }} + BASELINE_LABEL: ${{ github.event_name == 'workflow_dispatch' && needs.resolve_request.outputs.baseline_ref || 'synthetic-reverted-thread-filepath-fix' }} run: | set -euo pipefail @@ -629,32 +496,11 @@ jobs: name: Clear Mantis command reaction needs: [resolve_request, validate_candidate, run_thread_attachment] if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' && needs.resolve_request.outputs.reaction_id != '' }} - runs-on: ubuntu-24.04 permissions: {} - steps: - - name: Create Mantis cleanup GitHub App token - id: mantis_reaction_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Remove Mantis eyes reaction - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - REACTION_ID: ${{ needs.resolve_request.outputs.reaction_id }} - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - const reactionId = Number(process.env.REACTION_ID); - await github.rest.reactions.deleteForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - reaction_id: reactionId, - }); - core.info(`Removed Mantis eyes reaction ${reactionId}.`); + uses: ./.github/workflows/mantis-clear-reaction.yml + with: + comment-id: ${{ format('{0}', github.event.comment.id) }} + reaction-id: ${{ needs.resolve_request.outputs.reaction_id }} + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/mantis-resolve-request.yml b/.github/workflows/mantis-resolve-request.yml new file mode 100644 index 000000000000..486a6b81f520 --- /dev/null +++ b/.github/workflows/mantis-resolve-request.yml @@ -0,0 +1,244 @@ +name: Mantis Resolve Request + +on: + workflow_call: + inputs: + request-pattern: + description: JavaScript regex source matched against the normalized comment body + required: true + type: string + exclude-pattern: + description: Optional JavaScript regex source that suppresses a matching request + required: false + default: "" + type: string + skip-notice: + description: Notice emitted when a Mantis mention does not request this workflow + required: true + type: string + dispatch-params: + description: Workflow-specific dispatch inputs or issue-comment defaults as JSON + required: false + default: "{}" + type: string + runner: + description: Runner used for request resolution + required: false + default: ubuntu-24.04 + type: string + secrets: + MANTIS_GITHUB_APP_ID: + required: true + MANTIS_GITHUB_APP_PRIVATE_KEY: + required: true + outputs: + should_run: + value: ${{ jobs.resolve.outputs.should_run }} + request_source: + value: ${{ jobs.resolve.outputs.request_source }} + pr_number: + value: ${{ jobs.resolve.outputs.pr_number }} + candidate_ref: + value: ${{ jobs.resolve.outputs.candidate_ref }} + baseline_ref: + value: ${{ jobs.resolve.outputs.baseline_ref }} + reaction_id: + value: ${{ jobs.resolve.outputs.reaction_id }} + params: + value: ${{ jobs.resolve.outputs.params }} + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + resolve: + name: Resolve Mantis request + runs-on: ${{ inputs.runner }} + outputs: + baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} + candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} + params: ${{ steps.resolve.outputs.params }} + pr_number: ${{ steps.resolve.outputs.pr_number }} + reaction_id: ${{ steps.add_reaction.outputs.reaction_id }} + request_source: ${{ steps.resolve.outputs.request_source }} + should_run: ${{ steps.resolve.outputs.should_run }} + steps: + - name: Resolve refs and target PR + id: resolve + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + DISPATCH_PARAMS: ${{ inputs.dispatch-params }} + EXCLUDE_PATTERN: ${{ inputs.exclude-pattern }} + REQUEST_PATTERN: ${{ inputs.request-pattern }} + SKIP_NOTICE: ${{ inputs.skip-notice }} + with: + script: | + const eventName = context.eventName; + const dispatchParams = JSON.parse(process.env.DISPATCH_PARAMS || "{}"); + + function stringParam(value) { + return typeof value === "string" ? value : ""; + } + + function setOutput(name, value) { + core.setOutput(name, value ?? ""); + core.info(`${name}=${value ?? ""}`); + } + + function setResolvedOutputs({ baselineRef, candidateRef, params, prNumber, source }) { + setOutput("should_run", "true"); + setOutput("baseline_ref", baselineRef); + setOutput("candidate_ref", candidateRef); + setOutput("pr_number", prNumber); + setOutput("request_source", source); + setOutput("params", JSON.stringify(params)); + } + + async function getPullRequest(pullNumber) { + const { owner, repo } = context.repo; + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: Number(pullNumber), + }); + return pullRequest; + } + + if (eventName === "workflow_dispatch") { + const prNumber = stringParam(dispatchParams.pr_number); + const pullRequest = prNumber ? await getPullRequest(prNumber) : undefined; + const candidateRef = stringParam(dispatchParams.candidate_ref) || pullRequest?.head.sha || ""; + const baselineRef = stringParam(dispatchParams.baseline_ref) || pullRequest?.base.sha || ""; + const params = { + ...dispatchParams, + baseline_ref: baselineRef, + candidate_ref: candidateRef, + crabbox_provider: + stringParam(dispatchParams.crabbox_provider) || + stringParam(dispatchParams.provider), + lease_id: + stringParam(dispatchParams.crabbox_lease_id) || + stringParam(dispatchParams.lease_id) || + stringParam(dispatchParams.lease), + pr_number: prNumber, + provider_mode: + stringParam(dispatchParams.provider_mode) || + stringParam(dispatchParams["provider-mode"]), + scenario: stringParam(dispatchParams.scenario), + }; + setResolvedOutputs({ + baselineRef, + candidateRef, + params, + prNumber, + source: "workflow_dispatch", + }); + return; + } + + if (eventName !== "issue_comment") { + core.setFailed(`Unsupported event: ${eventName}`); + return; + } + + const issue = context.payload.issue; + const body = context.payload.comment?.body ?? ""; + if (!issue?.pull_request) { + core.setFailed("Mantis issue_comment trigger requires a pull request comment."); + return; + } + + const normalized = body.toLowerCase(); + const mentionsMantis = + normalized.includes("@openclaw-mantis") || + normalized.includes("/openclaw-mantis"); + const requested = new RegExp(process.env.REQUEST_PATTERN, "u").test(normalized); + const excluded = + process.env.EXCLUDE_PATTERN !== "" && + new RegExp(process.env.EXCLUDE_PATTERN, "u").test(normalized); + if (!mentionsMantis || !requested || excluded) { + core.notice(process.env.SKIP_NOTICE); + setOutput("should_run", "false"); + setOutput("baseline_ref", ""); + setOutput("candidate_ref", ""); + setOutput("pr_number", ""); + setOutput("request_source", "unsupported_issue_comment"); + setOutput("params", "{}"); + return; + } + + const pullRequest = await getPullRequest(issue.number); + const token = (keys) => { + const pattern = new RegExp( + `\\b(?:${keys.join("|")})\\s*[:=]\\s*\`?([^\\s\`]+)\`?`, + "i", + ); + return body.match(pattern)?.[1] ?? ""; + }; + const rawCandidate = token(["candidate", "head"]); + const candidateRef = + rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) + ? rawCandidate + : pullRequest.head.sha; + const baselineRef = + token(["baseline", "base"]) || + stringParam(dispatchParams.baseline_ref) || + pullRequest.base.sha; + const params = { + ...dispatchParams, + baseline_ref: baselineRef, + candidate_ref: candidateRef, + crabbox_provider: + token(["provider", "crabbox_provider"]) || + stringParam(dispatchParams.crabbox_provider) || + stringParam(dispatchParams.provider), + lease_id: + token(["lease", "lease_id", "crabbox_lease_id"]) || + stringParam(dispatchParams.crabbox_lease_id) || + stringParam(dispatchParams.lease_id) || + stringParam(dispatchParams.lease), + pr_number: String(issue.number), + provider_mode: + token(["provider_mode", "provider-mode"]) || + stringParam(dispatchParams.provider_mode) || + stringParam(dispatchParams["provider-mode"]), + scenario: + token(["scenario", "scenarios"]) || stringParam(dispatchParams.scenario), + }; + setResolvedOutputs({ + baselineRef, + candidateRef, + params, + prNumber: String(issue.number), + source: "issue_comment", + }); + + - name: Create Mantis reaction GitHub App token + id: mantis_reaction_token + if: ${{ steps.resolve.outputs.request_source == 'issue_comment' }} + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} + private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-issues: write + + - name: Add Mantis eyes reaction + id: add_reaction + if: ${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ steps.mantis_reaction_token.outputs.token }} + script: | + const { owner, repo } = context.repo; + await github.rest.reactions + .createForIssueComment({ + owner, + repo, + comment_id: context.payload.comment.id, + content: "eyes", + }) + .then(({ data: reaction }) => core.setOutput("reaction_id", String(reaction.id))) + .catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); diff --git a/.github/workflows/mantis-slack-desktop-smoke.yml b/.github/workflows/mantis-slack-desktop-smoke.yml index 210704345a95..6093b3e27592 100644 --- a/.github/workflows/mantis-slack-desktop-smoke.yml +++ b/.github/workflows/mantis-slack-desktop-smoke.yml @@ -108,7 +108,7 @@ jobs: if: needs.authorize_actor.outputs.authorized == 'true' runs-on: ubuntu-24.04 outputs: - candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_revision: ${{ steps.validate.outputs.candidate-revision }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -118,44 +118,11 @@ jobs: - name: Validate ref is trusted id: validate + uses: ./.github/actions/mantis-validate-trusted-ref env: GH_TOKEN: ${{ github.token }} - CANDIDATE_REF: ${{ inputs.candidate_ref }} - shell: bash - run: | - set -euo pipefail - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - - revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" - reason="" - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 - exit 1 - fi - - echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" - { - echo "candidate: \`${CANDIDATE_REF}\`" - echo "candidate SHA: \`${revision}\`" - echo "candidate trust reason: \`${reason}\`" - } >> "$GITHUB_STEP_SUMMARY" + with: + candidate-ref: ${{ inputs.candidate_ref }} run_slack_desktop: name: Run Slack desktop smoke diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index b496dd062540..e8a74c88466a 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -123,6 +123,8 @@ jobs: persist-credentials: false fetch-depth: 0 + # Intentionally separate: the candidate is the selected open PR head SHA, with fork + # heads allowed as fork-pr-head, while only the baseline uses main-ancestor trust. - name: Validate refs are trusted id: validate env: diff --git a/.github/workflows/mantis-telegram-live.yml b/.github/workflows/mantis-telegram-live.yml index 10cb06f81387..f4e279a85445 100644 --- a/.github/workflows/mantis-telegram-live.yml +++ b/.github/workflows/mantis-telegram-live.yml @@ -100,151 +100,49 @@ jobs: name: Resolve Mantis request needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' + uses: ./.github/workflows/mantis-resolve-request.yml + with: + request-pattern: telegram + exclude-pattern: desktop proof|desktop-proof|telegram desktop|native telegram|visible proof|visible-proof|telegram-visible-proof + skip-notice: Comment mentioned Mantis but did not request Telegram live QA. + dispatch-params: >- + ${{ + github.event_name == 'workflow_dispatch' && toJSON(inputs) || + '{"scenario":"telegram-status-command","provider_mode":"live-frontier","crabbox_provider":"aws"}' + }} + runner: ubuntu-24.04 + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} + + validate_request: + name: Validate Telegram request + needs: resolve_request + if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: ubuntu-24.04 - outputs: - candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} - crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }} - lease_id: ${{ steps.resolve.outputs.lease_id }} - pr_number: ${{ steps.resolve.outputs.pr_number }} - provider_mode: ${{ steps.resolve.outputs.provider_mode }} - reaction_id: ${{ steps.add_reaction.outputs.reaction_id }} - request_source: ${{ steps.resolve.outputs.request_source }} - scenario: ${{ steps.resolve.outputs.scenario }} - should_run: ${{ steps.resolve.outputs.should_run }} + permissions: {} steps: - - name: Resolve refs and target PR - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const eventName = context.eventName; - - function setOutput(name, value) { - core.setOutput(name, value ?? ""); - core.info(`${name}=${value ?? ""}`); - } - - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - const providerMode = inputs.provider_mode || "live-frontier"; - if (!["live-frontier", "mock-openai"].includes(providerMode)) { - core.setFailed(`Unsupported provider mode for Mantis Telegram: ${providerMode}`); - return; - } - setOutput("should_run", "true"); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("scenario", inputs.scenario || "telegram-status-command"); - setOutput("provider_mode", providerMode); - setOutput("crabbox_provider", inputs.crabbox_provider || "aws"); - setOutput("lease_id", inputs.crabbox_lease_id || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const requestedDesktopProof = - normalized.includes("desktop proof") || - normalized.includes("desktop-proof") || - normalized.includes("telegram desktop") || - normalized.includes("native telegram") || - normalized.includes("visible proof") || - normalized.includes("visible-proof") || - normalized.includes("telegram-visible-proof"); - const requested = - (normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) && - normalized.includes("telegram") && - !requestedDesktopProof; - if (!requested) { - core.notice("Comment mentioned Mantis but did not request Telegram live QA."); - setOutput("should_run", "false"); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("scenario", ""); - setOutput("provider_mode", ""); - setOutput("crabbox_provider", ""); - setOutput("lease_id", ""); - setOutput("request_source", "unsupported_issue_comment"); - return; - } - - const { owner, repo } = context.repo; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue.number, - }); - const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); - const scenarioMatch = body.match(/(?:scenario|scenarios)[\s:=]+([^\s`]+)/i); - const providerModeMatch = body.match(/(?:provider_mode|provider-mode)[\s:=]+([^\s`]+)/i); - const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i); - const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i); - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : pr.head.sha; - const provider = providerMatch?.[1] || "aws"; - if (!["aws", "hetzner"].includes(provider)) { - core.setFailed(`Unsupported Crabbox provider for Mantis Telegram: ${provider}`); - return; - } - const providerMode = providerModeMatch?.[1] || "live-frontier"; - if (!["live-frontier", "mock-openai"].includes(providerMode)) { - core.setFailed(`Unsupported provider mode for Mantis Telegram: ${providerMode}`); - return; - } - - setOutput("should_run", "true"); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); - setOutput("scenario", scenarioMatch?.[1] || "telegram-status-command"); - setOutput("provider_mode", providerMode); - setOutput("crabbox_provider", provider); - setOutput("lease_id", leaseMatch?.[1] || ""); - setOutput("request_source", "issue_comment"); - - - name: Create Mantis reaction GitHub App token - id: mantis_reaction_token - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' }} - continue-on-error: true - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Add Mantis eyes reaction - id: add_reaction - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - await github.rest.reactions - .createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }) - .then(({ data: reaction }) => core.setOutput("reaction_id", String(reaction.id))) - .catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + - name: Validate Telegram request parameters + env: + CRABBOX_PROVIDER: ${{ fromJSON(needs.resolve_request.outputs.params).crabbox_provider }} + PROVIDER_MODE: ${{ fromJSON(needs.resolve_request.outputs.params).provider_mode }} + shell: bash + run: | + set -euo pipefail + case "$CRABBOX_PROVIDER" in + aws | hetzner) ;; + *) + echo "Unsupported Crabbox provider for Mantis Telegram: ${CRABBOX_PROVIDER}" >&2 + exit 1 + ;; + esac + case "$PROVIDER_MODE" in + live-frontier | mock-openai) ;; + *) + echo "Unsupported provider mode for Mantis Telegram: ${PROVIDER_MODE}" >&2 + exit 1 + ;; + esac validate_ref: name: Validate candidate ref @@ -252,7 +150,7 @@ jobs: if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: ubuntu-24.04 outputs: - candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_revision: ${{ steps.validate.outputs.candidate-revision }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -262,48 +160,15 @@ jobs: - name: Validate ref is trusted id: validate + uses: ./.github/actions/mantis-validate-trusted-ref env: GH_TOKEN: ${{ github.token }} - CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} - shell: bash - run: | - set -euo pipefail - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - - revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" - reason="" - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 - exit 1 - fi - - echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" - { - echo "candidate: \`${CANDIDATE_REF}\`" - echo "candidate SHA: \`${revision}\`" - echo "candidate trust reason: \`${reason}\`" - } >> "$GITHUB_STEP_SUMMARY" + with: + candidate-ref: ${{ needs.resolve_request.outputs.candidate_ref }} run_telegram_live: name: Run Telegram live QA with Crabbox evidence - needs: [resolve_request, validate_ref] + needs: [resolve_request, validate_request, validate_ref] if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 180 @@ -433,10 +298,10 @@ jobs: CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }} CRABBOX_AWS_REGION: ${{ env.CRABBOX_AWS_REGION }} CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }} - CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }} - CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} - PROVIDER_MODE: ${{ needs.resolve_request.outputs.provider_mode }} - SCENARIO_INPUT: ${{ needs.resolve_request.outputs.scenario }} + CRABBOX_LEASE_ID: ${{ fromJSON(needs.resolve_request.outputs.params).lease_id }} + CRABBOX_PROVIDER: ${{ fromJSON(needs.resolve_request.outputs.params).crabbox_provider }} + PROVIDER_MODE: ${{ fromJSON(needs.resolve_request.outputs.params).provider_mode }} + SCENARIO_INPUT: ${{ fromJSON(needs.resolve_request.outputs.params).scenario }} CANDIDATE_SHA: ${{ needs.validate_ref.outputs.candidate_revision }} shell: bash run: | @@ -625,32 +490,11 @@ jobs: name: Clear Mantis command reaction needs: [resolve_request, validate_ref, run_telegram_live] if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' && needs.resolve_request.outputs.reaction_id != '' }} - runs-on: ubuntu-24.04 permissions: {} - steps: - - name: Create Mantis cleanup GitHub App token - id: mantis_reaction_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Remove Mantis eyes reaction - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - REACTION_ID: ${{ needs.resolve_request.outputs.reaction_id }} - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - const reactionId = Number(process.env.REACTION_ID); - await github.rest.reactions.deleteForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - reaction_id: reactionId, - }); - core.info(`Removed Mantis eyes reaction ${reactionId}.`); + uses: ./.github/workflows/mantis-clear-reaction.yml + with: + comment-id: ${{ format('{0}', github.event.comment.id) }} + reaction-id: ${{ needs.resolve_request.outputs.reaction_id }} + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/mantis-web-ui-chat-proof.yml b/.github/workflows/mantis-web-ui-chat-proof.yml index e5fd9508fdaf..cfc22c711f7f 100644 --- a/.github/workflows/mantis-web-ui-chat-proof.yml +++ b/.github/workflows/mantis-web-ui-chat-proof.yml @@ -74,111 +74,15 @@ jobs: name: Resolve Mantis request needs: authorize_actor if: needs.authorize_actor.outputs.authorized == 'true' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} - pr_number: ${{ steps.resolve.outputs.pr_number }} - reaction_id: ${{ steps.add_reaction.outputs.reaction_id }} - request_source: ${{ steps.resolve.outputs.request_source }} - should_run: ${{ steps.resolve.outputs.should_run }} - steps: - - name: Resolve ref and target PR - id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - const eventName = context.eventName; - - function setOutput(name, value) { - core.setOutput(name, value ?? ""); - core.info(`${name}=${value ?? ""}`); - } - - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - setOutput("should_run", "true"); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const mentionsMantis = - normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis"); - const requestedWebUiChat = - normalized.includes("web-ui-chat") || - normalized.includes("web ui chat") || - (normalized.includes("web ui") && normalized.includes("chat")) || - (normalized.includes("control ui") && normalized.includes("chat")); - if (!mentionsMantis || !requestedWebUiChat) { - core.notice("Comment mentioned Mantis but did not request web UI chat proof."); - setOutput("should_run", "false"); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("request_source", "unsupported_issue_comment"); - return; - } - - const { owner, repo } = context.repo; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue.number, - }); - const candidateMatch = body.match(/\b(?:candidate|head)\s*[:=]\s*`?([^\s`]+)`?/i); - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : pr.head.sha; - - setOutput("should_run", "true"); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); - setOutput("request_source", "issue_comment"); - - - name: Create Mantis reaction GitHub App token - id: mantis_reaction_token - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' }} - continue-on-error: true - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Add Mantis eyes reaction - id: add_reaction - if: ${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - await github.rest.reactions - .createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }) - .then(({ data: reaction }) => core.setOutput("reaction_id", String(reaction.id))) - .catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + uses: ./.github/workflows/mantis-resolve-request.yml + with: + request-pattern: 'web-ui-chat|web ui chat|(?=[\s\S]*web ui)(?=[\s\S]*chat)|(?=[\s\S]*control ui)(?=[\s\S]*chat)' + skip-notice: Comment mentioned Mantis but did not request web UI chat proof. + dispatch-params: ${{ github.event_name == 'workflow_dispatch' && toJSON(inputs) || '{}' }} + runner: blacksmith-8vcpu-ubuntu-2404 + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} validate_candidate: name: Validate selected candidate @@ -186,7 +90,7 @@ jobs: if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: - candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_revision: ${{ steps.validate.outputs.candidate-revision }} steps: - name: Checkout harness ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -196,44 +100,11 @@ jobs: - name: Validate candidate ref is trusted id: validate + uses: ./.github/actions/mantis-validate-trusted-ref env: GH_TOKEN: ${{ github.token }} - CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} - shell: bash - run: | - set -euo pipefail - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - - revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")" - reason="" - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - fi - fi - - if [[ -z "$reason" ]]; then - echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this Mantis run." >&2 - exit 1 - fi - - echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT" - { - echo "candidate: \`${CANDIDATE_REF}\`" - echo "candidate SHA: \`${revision}\`" - echo "candidate trust reason: \`${reason}\`" - } >> "$GITHUB_STEP_SUMMARY" + with: + candidate-ref: ${{ needs.resolve_request.outputs.candidate_ref }} run_web_ui_chat: name: Run Control UI web chat proof @@ -395,32 +266,11 @@ jobs: name: Clear Mantis command reaction needs: [resolve_request, validate_candidate, run_web_ui_chat, publish_evidence] if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' && needs.resolve_request.outputs.reaction_id != '' }} - runs-on: ubuntu-24.04 permissions: {} - steps: - - name: Create Mantis cleanup GitHub App token - id: mantis_reaction_token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }} - private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-issues: write - - - name: Remove Mantis eyes reaction - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - REACTION_ID: ${{ needs.resolve_request.outputs.reaction_id }} - with: - github-token: ${{ steps.mantis_reaction_token.outputs.token }} - script: | - const { owner, repo } = context.repo; - const reactionId = Number(process.env.REACTION_ID); - await github.rest.reactions.deleteForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - reaction_id: reactionId, - }); - core.info(`Removed Mantis eyes reaction ${reactionId}.`); + uses: ./.github/workflows/mantis-clear-reaction.yml + with: + comment-id: ${{ format('{0}', github.event.comment.id) }} + reaction-id: ${{ needs.resolve_request.outputs.reaction_id }} + secrets: + MANTIS_GITHUB_APP_ID: ${{ secrets.MANTIS_GITHUB_APP_ID }} + MANTIS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }} diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 04d2774388aa..5c63e9733b7c 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -3917,72 +3917,111 @@ NODE ).toBe(true); }); - it.each(MANTIS_ISSUE_COMMENT_REACTION_WORKFLOWS)( - "keeps Mantis reaction ownership stable in %s", - (workflowPath) => { - const source = readFileSync(workflowPath, "utf8"); - const workflow = parse(source); - const resolveJob = workflow.jobs.resolve_request; - const resolveSteps = resolveJob.steps as WorkflowStep[]; - const cleanupJob = workflow.jobs.clear_issue_comment_reaction; - const cleanupSteps = cleanupJob.steps as WorkflowStep[]; - const findStep = (steps: WorkflowStep[], id: string) => - expectDefined( - steps.find((step) => step.id === id), - `${workflowPath} ${id}`, - ); - const createTokenStep = findStep(resolveSteps, "mantis_reaction_token"); - const createStep = findStep(resolveSteps, "add_reaction"); - const cleanupTokenStep = findStep(cleanupSteps, "mantis_reaction_token"); - const deleteStep = expectDefined( - cleanupSteps.find((step) => step.env?.REACTION_ID), - `${workflowPath} reaction cleanup step`, + it("keeps shared Mantis reaction ownership stable", () => { + const resolveWorkflowPath = ".github/workflows/mantis-resolve-request.yml"; + const cleanupWorkflowPath = ".github/workflows/mantis-clear-reaction.yml"; + const resolveSource = readFileSync(resolveWorkflowPath, "utf8"); + const cleanupSource = readFileSync(cleanupWorkflowPath, "utf8"); + const resolveWorkflow = parse(resolveSource); + const cleanupWorkflow = parse(cleanupSource); + const expectedWorkflowCallSecrets = { + MANTIS_GITHUB_APP_ID: { required: true }, + MANTIS_GITHUB_APP_PRIVATE_KEY: { required: true }, + }; + const resolveJob = resolveWorkflow.jobs.resolve; + const cleanupJob = cleanupWorkflow.jobs.clear; + const resolveSteps = resolveJob.steps as WorkflowStep[]; + const cleanupSteps = cleanupJob.steps as WorkflowStep[]; + const findStep = (steps: WorkflowStep[], id: string, workflowPath: string) => + expectDefined( + steps.find((step) => step.id === id), + `${workflowPath} ${id}`, ); + const createTokenStep = findStep(resolveSteps, "mantis_reaction_token", resolveWorkflowPath); + const createStep = findStep(resolveSteps, "add_reaction", resolveWorkflowPath); + const cleanupTokenStep = findStep(cleanupSteps, "mantis_reaction_token", cleanupWorkflowPath); + const deleteStep = expectDefined( + cleanupSteps.find((step) => step.env?.REACTION_ID), + `${cleanupWorkflowPath} reaction cleanup step`, + ); - expect(resolveJob.outputs.reaction_id, workflowPath).toBe( - "${{ steps.add_reaction.outputs.reaction_id }}", - ); - for (const [label, tokenStep] of [ - ["creation", createTokenStep], - ["cleanup", cleanupTokenStep], - ] as const) { - expect(tokenStep, `${workflowPath} ${label} token`).toMatchObject({ - uses: CREATE_GITHUB_APP_TOKEN_V3, - with: { - "app-id": "${{ secrets.MANTIS_GITHUB_APP_ID }}", - "private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}", - }, - }); - expect( - Object.entries(tokenStep.with ?? {}).filter(([key]) => key.startsWith("permission-")), - `${workflowPath} ${label} permissions`, - ).toEqual([["permission-issues", "write"]]); - } - expect(createStep, workflowPath).toMatchObject({ - if: "${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }}", - uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", - with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" }, + expect(resolveWorkflow.on.workflow_call.secrets, resolveWorkflowPath).toEqual( + expectedWorkflowCallSecrets, + ); + expect(cleanupWorkflow.on.workflow_call.secrets, cleanupWorkflowPath).toEqual( + expectedWorkflowCallSecrets, + ); + expect(resolveJob.outputs.reaction_id, resolveWorkflowPath).toBe( + "${{ steps.add_reaction.outputs.reaction_id }}", + ); + for (const [label, tokenStep] of [ + ["creation", createTokenStep], + ["cleanup", cleanupTokenStep], + ] as const) { + expect(tokenStep, `${label} token`).toMatchObject({ + uses: CREATE_GITHUB_APP_TOKEN_V3, + with: { + "app-id": "${{ secrets.MANTIS_GITHUB_APP_ID }}", + "private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}", + }, }); - expect(createStep.with?.script, workflowPath).toContain("createForIssueComment"); - expect(createStep.with?.script, workflowPath).toContain( - 'core.setOutput("reaction_id", String(reaction.id))', - ); - expect(source.match(/createForIssueComment/gu), workflowPath).toHaveLength(1); + expect( + Object.entries(tokenStep.with ?? {}).filter(([key]) => key.startsWith("permission-")), + `${label} permissions`, + ).toEqual([["permission-issues", "write"]]); + } + expect(createStep, resolveWorkflowPath).toMatchObject({ + if: "${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }}", + uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", + with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" }, + }); + expect(createStep.with?.script, resolveWorkflowPath).toContain("createForIssueComment"); + expect(createStep.with?.script, resolveWorkflowPath).toContain( + 'core.setOutput("reaction_id", String(reaction.id))', + ); + expect(resolveSource.match(/createForIssueComment/gu), resolveWorkflowPath).toHaveLength(1); + expect(cleanupJob.permissions, cleanupWorkflowPath).toEqual({}); + expect(deleteStep, cleanupWorkflowPath).toMatchObject({ + env: { + COMMENT_ID: "${{ inputs.comment-id }}", + REACTION_ID: "${{ inputs.reaction-id }}", + }, + uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", + with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" }, + }); + expect(deleteStep.with?.script, cleanupWorkflowPath).toContain("deleteForIssueComment"); + expect(deleteStep.with?.script, cleanupWorkflowPath).toContain( + "Number(process.env.REACTION_ID)", + ); + expect(deleteStep.with?.script, cleanupWorkflowPath).toContain("reaction_id: reactionId"); + expect(JSON.stringify(cleanupJob), cleanupWorkflowPath).not.toMatch( + /listForIssueComment|\.filter\(|github-actions\[bot\]/u, + ); + }); + + it.each(MANTIS_ISSUE_COMMENT_REACTION_WORKFLOWS)( + "routes Mantis reaction ownership through shared workflows in %s", + (workflowPath) => { + const workflow = parse(readFileSync(workflowPath, "utf8")); + const resolveJob = workflow.jobs.resolve_request; + const cleanupJob = workflow.jobs.clear_issue_comment_reaction; + const expectedSecrets = { + MANTIS_GITHUB_APP_ID: "${{ secrets.MANTIS_GITHUB_APP_ID }}", + MANTIS_GITHUB_APP_PRIVATE_KEY: "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}", + }; + + expect(resolveJob.uses, workflowPath).toBe("./.github/workflows/mantis-resolve-request.yml"); + expect(resolveJob.secrets, workflowPath).toEqual(expectedSecrets); + expect(cleanupJob.uses, workflowPath).toBe("./.github/workflows/mantis-clear-reaction.yml"); expect(cleanupJob.if, workflowPath).toContain( "needs.resolve_request.outputs.reaction_id != ''", ); expect(cleanupJob.permissions, workflowPath).toEqual({}); - expect(deleteStep, workflowPath).toMatchObject({ - env: { REACTION_ID: "${{ needs.resolve_request.outputs.reaction_id }}" }, - uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", - with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" }, + expect(cleanupJob.with, workflowPath).toMatchObject({ + "comment-id": "${{ format('{0}', github.event.comment.id) }}", + "reaction-id": "${{ needs.resolve_request.outputs.reaction_id }}", }); - expect(deleteStep.with?.script, workflowPath).toContain("deleteForIssueComment"); - expect(deleteStep.with?.script, workflowPath).toContain("Number(process.env.REACTION_ID)"); - expect(deleteStep.with?.script, workflowPath).toContain("reaction_id: reactionId"); - expect(JSON.stringify(cleanupJob), workflowPath).not.toMatch( - /listForIssueComment|\.filter\(|github-actions\[bot\]/u, - ); + expect(cleanupJob.secrets, workflowPath).toEqual(expectedSecrets); }, ); diff --git a/test/scripts/mantis-web-ui-chat-proof-workflow.test.ts b/test/scripts/mantis-web-ui-chat-proof-workflow.test.ts index c5d30652b927..e53f023d1bc5 100644 --- a/test/scripts/mantis-web-ui-chat-proof-workflow.test.ts +++ b/test/scripts/mantis-web-ui-chat-proof-workflow.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; const WORKFLOW = ".github/workflows/mantis-web-ui-chat-proof.yml"; +const SHARED_RESOLVE_WORKFLOW = ".github/workflows/mantis-resolve-request.yml"; type WorkflowStep = { name?: string; @@ -22,11 +23,11 @@ type Workflow = { }; function resolveRequestScript(): string { - const workflow = parse(readFileSync(WORKFLOW, "utf8")) as Workflow; - const steps = workflow.jobs?.resolve_request?.steps ?? []; - const step = steps.find((candidate) => candidate.name === "Resolve ref and target PR"); + const workflow = parse(readFileSync(SHARED_RESOLVE_WORKFLOW, "utf8")) as Workflow; + const steps = workflow.jobs?.resolve?.steps ?? []; + const step = steps.find((candidate) => candidate.name === "Resolve refs and target PR"); if (!step?.with?.script) { - throw new Error("Missing Resolve ref and target PR script"); + throw new Error("Missing shared Resolve refs and target PR script"); } return step.with.script; } @@ -42,16 +43,31 @@ function workflowJob(name: string): WorkflowJob { function candidateOverridePattern(): RegExp { const script = resolveRequestScript(); - const match = script.match(/const candidateMatch = body\.match\((\/.*\/i)\);/); - if (!match) { - throw new Error("Missing candidate override regex"); + const template = script.match( + /const pattern = new RegExp\(\s*`((?:\\`|[^`])*)`,\s*"i",\s*\);/u, + )?.[1]; + const keysLiteral = script.match(/const rawCandidate = token\((\[[^\n]+\])\);/u)?.[1]; + if (!template || !keysLiteral) { + throw new Error("Missing shared candidate token pattern"); } - const literal = match[1]; - if (!literal) { - throw new Error("Missing candidate override regex literal"); + const keys = JSON.parse(keysLiteral) as string[]; + if (keys.join("|") !== "candidate|head") { + throw new Error(`Unexpected candidate token keys: ${keys.join("|")}`); } - const flagsStart = literal.lastIndexOf("/"); - return new RegExp(literal.slice(1, flagsStart), literal.slice(flagsStart + 1)); + const instantiated = template.replace('${keys.join("|")}', keys.join("|")).replaceAll("\\`", "`"); + const source = JSON.parse(`"${instantiated.replaceAll('"', '\\"')}"`) as string; + return new RegExp(source, "i"); +} + +function resolveCandidateRef(body: string, pullRequestHead: string): string { + const script = resolveRequestScript(); + if (!script.includes('!["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase())')) { + throw new Error("Missing shared PR-head candidate aliases"); + } + const rawCandidate = body.match(candidateOverridePattern())?.[1]; + return rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) + ? rawCandidate + : pullRequestHead; } describe("Mantis Web UI chat proof workflow", () => { @@ -98,5 +114,13 @@ describe("Mantis Web UI chat proof workflow", () => { pattern, )?.[1], ).toBe("e63393c"); + + const pullRequestHead = "f00ba4"; + expect(resolveCandidateRef("verify this PR head produces evidence", pullRequestHead)).toBe( + pullRequestHead, + ); + expect(resolveCandidateRef("candidate: head", pullRequestHead)).toBe(pullRequestHead); + expect(resolveCandidateRef("candidate=pr", pullRequestHead)).toBe(pullRequestHead); + expect(resolveCandidateRef("head: pr-head", pullRequestHead)).toBe(pullRequestHead); }); });