ci(mantis): extract shared request-resolution, ref-trust, and reaction workflows (#121339)

* ci(mantis): extract shared request, trust, and reaction workflows

* ci(mantis): drop caller-less params from shared resolve workflow

* test(mantis): read candidate-override parsing from shared resolve workflow
This commit is contained in:
Peter Steinberger
2026-08-09 22:04:27 -07:00
committed by GitHub
parent 9c3241f19f
commit ff10db092b
11 changed files with 635 additions and 854 deletions
@@ -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"
@@ -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}.`);
@@ -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 }}
@@ -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 }}
@@ -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}`));
@@ -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
@@ -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:
+57 -213
View File
@@ -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 }}
+20 -170
View File
@@ -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 }}
+97 -58
View File
@@ -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);
},
);
@@ -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);
});
});