name: Marvin Label Triage # Automatically triage GitHub issues and PRs using Marvin on: issues: types: [opened] pull_request_target: types: [opened] workflow_dispatch: inputs: issue_number: description: "Issue or PR number to triage" required: true type: string concurrency: group: triage-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }} cancel-in-progress: false jobs: label-issue-or-pr: if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write pull-requests: write # TEMPORARY PIN — remove once upstream ships a fix. # # Claude Code 2.1.216 regressed the sandbox that claude-code-action wraps # every Bash call in when `allowed_non_write_users` is set: the mountpoint # walk fails closed, so every command — down to `true` — dies with # `bwrap: Can't create file at /home/.mcp.json: Permission denied`. # Marvin still reads the issue and picks correct labels, then cannot run # the helper that applies them, so triage silently applied zero labels # from 2026-07-20 onward while every run reported success. # # 2.1.215 is the last release without the regression. # https://github.com/anthropics/claude-code/issues/79997 # https://github.com/anthropics/claude-code-action/issues/1547 env: PINNED_CLAUDE_CODE_VERSION: "2.1.215" steps: - name: Checkout base repository uses: actions/checkout@v7 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} # No `owner:` — with it set and `repositories:` empty the token is # scoped to every repo in the PrefectHQ installation. Triage only # ever touches this one. The permissions below match the job's # `permissions:` block; unscoped the token would also carry # contents: write and actions: write from the App installation. permission-contents: read permission-issues: write permission-pull-requests: write - name: Set triage prompt id: triage-prompt run: | cat >> $GITHUB_OUTPUT << 'EOF' PROMPT<> "$GITHUB_OUTPUT" "$HOME/.local/bin/claude" --version - name: Run Marvin for Issue Triage id: marvin uses: anthropics/claude-code-action@v1 with: path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }} github_token: ${{ steps.marvin-token.outputs.token }} bot_name: "Marvin Context Protocol" prompt: ${{ steps.triage-prompt.outputs.PROMPT }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} allowed_non_write_users: "*" allowed_bots: "marvin-context-protocol" claude_args: | --allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files settings: | { "model": "claude-sonnet-5", "env": { "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}", "TRIAGE_REPO": "${{ github.repository }}", "TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}" } } # Triage is fire-and-forget: nobody watches a green run, so a broken # allowlist has to fail the job or it goes unnoticed indefinitely — a # mangled pattern silently produced zero labels across a dozen PRs # because the run still reported success. # # Only denials of commands we MEANT to grant indicate that breakage. An # agent reaching for something never on the allowlist (falling back to # `gh issue view` when the API is down, say) is behaving normally, and # failing on that would cry wolf during every GitHub incident. - name: Fail if Marvin could not run its tools if: always() && steps.marvin.conclusion != 'skipped' env: EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }} run: | file="${EXECUTION_FILE:-}" if [[ -z "$file" || ! -s "$file" ]]; then file="${RUNNER_TEMP}/claude-execution-output.json" fi # A missing or empty log means we cannot tell a clean run from a # blocked one, which is the exact failure this step exists to catch. if [[ ! -s "$file" ]]; then echo "::error::No Marvin execution log found; cannot verify tool permissions." exit 1 fi # The persisted log carries a `permission_denials` array on each # `type: result` entry; the `permission_denials_count` scalar only # appears in the action's condensed stdout summary, never on disk. # Anchor to result entries rather than recursing with `..`, which # descends into each denial's `tool_input` and double-counts any # denied command that happens to mention the field name. if ! summary=$(jq -sr ' [ .[] | if type == "array" then .[] else . end ] | map(select(type == "object" and .type == "result")) | map(.permission_denials // []) | flatten | map(.tool_input.command // "") | { total: length, granted: map(select( startswith("gh label list") or startswith("bash .github/scripts/triage-label.sh") )) } | "\(.total)\t\(.granted | length)\t\(.granted | join(" | "))" ' "$file"); then echo "::error::Could not parse Marvin execution log ($file)." exit 1 fi IFS=$'\t' read -r total granted commands <<<"$summary" echo "Denied tool calls: $total (of which allowlisted: $granted)" if [[ "$granted" -gt 0 ]]; then echo "::error::Marvin was denied $granted call(s) to tools this workflow grants, so it could not apply labels: ${commands}. The --allowedTools value is not reaching the permission matcher intact — claude_args is lexed with shell-quote, so any Bash(...) pattern containing a space must be quoted or it is split into fragments." exit 1 fi if [[ "$total" -gt 0 ]]; then echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact." fi # A granted tool can also fail *after* the permission check, which the # denial count above cannot see. Claude Code 2.1.216 did exactly that: # the sandbox refused to build and every Bash call — including the # labeling helper — exited 1 with `bwrap: ...`, while the run stayed # green. Correlate results back to their Bash tool_use rather than # grepping the whole log, so an issue body quoting a sandbox error # cannot fail an otherwise healthy run. if ! sandbox=$(jq -sr ' [ .[] | if type == "array" then .[] else . end ] | map(select(type == "object" and (.type == "assistant" or .type == "user"))) | map(.message.content // []) | flatten | map(select(type == "object")) | . as $blocks | ( $blocks | map(select(.type == "tool_use" and .name == "Bash")) | map(.id) ) as $bash | $blocks | map(select(.type == "tool_result" and (.tool_use_id as $i | $bash | index($i)))) | map(.content | tostring) | map(select(test("bwrap:|Failed to (start|create) sandbox"))) | "\(length)\t\(.[0] // "" | gsub("[\t\n]"; " ") | .[0:200])" ' "$file"); then echo "::error::Could not scan Marvin execution log for sandbox failures ($file)." exit 1 fi IFS=$'\t' read -r sandbox_failures sandbox_sample <<<"$sandbox" if [[ "$sandbox_failures" -gt 0 ]]; then echo "::error::Marvin's Bash tool failed $sandbox_failures time(s) inside the action's subprocess sandbox, so it could not apply labels: ${sandbox_sample}. This is an environment failure, not a prompt or allowlist problem — check whether the pinned Claude Code version (${PINNED_CLAUDE_CODE_VERSION}) still avoids the upstream sandbox regression." exit 1 fi - name: Upload Marvin execution log if: always() && steps.marvin.conclusion != 'skipped' uses: actions/upload-artifact@v7 with: name: marvin-triage-execution-log path: | ${{ steps.marvin.outputs.execution_file }} ${{ runner.temp }}/claude-execution-output.json if-no-files-found: ignore retention-days: 14