mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* ci: trigger PR exploration via maintainer "/explore" comment (no approval) Add a low-friction way to run the sandbox exploration: a maintainer comments "/explore" on a PR. - on: issue_comment (kept workflow_dispatch). The job `if` allows the comment path only when it is on a PR and the commenter has write access (author_association OWNER/MEMBER/COLLABORATOR), so randoms cannot trigger it; untrusted PR code still runs only inside the Docker sandbox. - Drop the agent-pr-explore environment approval gate: both triggers are already write-gated and there is no auto-trigger, so the extra manual approval is redundant. R2 creds are repo-level secrets (no env-scoped secrets), so they stay available without the environment. - Feedback: 👀 reaction on the command + a placeholder comment carrying the report marker (so the run yields one evolving comment), 🚀 on success, and 👎 + a failure note (with the run link) on failure. Does not auto-run on every PR, so unrelated PRs stay clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: don't clobber a produced report with the /explore failure note Review: the failure-feedback step ran after the always() report step, so on the failure-with-report case (sandbox wrote a report then exited non-zero) it overwrote the just-posted report with the generic "failed before producing a report" note — losing the useful output. Guard it: if the report file exists, leave the posted report in place and skip the failure note/reaction. Only post the short failure note when no report was produced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
15 KiB
YAML
330 lines
15 KiB
YAML
name: agent-pr-explore-sandbox
|
|
|
|
# Trusted-orchestrator workflow for PR exploration on the self-hosted runner.
|
|
# Triggered by manual workflow_dispatch, or by a maintainer commenting
|
|
# "/explore" on a PR (write-access gated). It deliberately does not auto-run on
|
|
# every PR, so ordinary PRs do not accumulate waiting checks; PR code only ever
|
|
# runs inside the Docker sandbox without secrets.
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
pr_number:
|
|
description: Pull request number to explore.
|
|
required: true
|
|
type: string
|
|
skip_comment:
|
|
description: Skip posting the exploration report comment on the PR (the report is still uploaded as an artifact). Use for validation/dry runs.
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
# A maintainer commenting "/explore" on a PR explores that PR. issue_comment
|
|
# runs in the base (trusted) context with secrets, so the job `if` gate below
|
|
# restricts it to users with write access; untrusted PR code still runs only
|
|
# inside the Docker sandbox without secrets.
|
|
issue_comment:
|
|
types: [created]
|
|
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
group: agent-pr-explore-sandbox-${{ github.event.issue.number || inputs.pr_number }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
sandbox:
|
|
name: Sandbox PR runtime
|
|
runs-on: [self-hosted, agent-pr-explore]
|
|
# No environment approval gate: both triggers are already write-access
|
|
# gated (workflow_dispatch needs repo write; the "/explore" comment path is
|
|
# gated on author_association below), and PR code runs only in the sandbox.
|
|
# R2 credentials are repo-level secrets, so they remain available here.
|
|
timeout-minutes: 45
|
|
# Manual dispatch is always allowed. A "/explore" PR comment is allowed only
|
|
# from a user with write access (OWNER/MEMBER/COLLABORATOR), never on issues.
|
|
if: >-
|
|
github.event_name == 'workflow_dispatch' ||
|
|
(github.event_name == 'issue_comment' &&
|
|
github.event.issue.pull_request != null &&
|
|
startsWith(github.event.comment.body, '/explore') &&
|
|
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
|
|
|
steps:
|
|
- name: Acknowledge /explore command
|
|
if: ${{ github.event_name == 'issue_comment' }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
COMMENT_ID: ${{ github.event.comment.id }}
|
|
PR_NUMBER: ${{ github.event.issue.number }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
run: |
|
|
set -uo pipefail
|
|
# 👀 on the command comment = picked up.
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=eyes --silent || true
|
|
# Post a placeholder carrying the report marker; the report step later
|
|
# PATCHes this same comment, so the run produces one evolving comment.
|
|
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
|
body="🔍 Exploring this PR in an isolated sandbox — [follow the run]($RUN_URL). The report will replace this comment when it is ready.
|
|
|
|
$marker"
|
|
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
|
|
if [ -n "$existing" ]; then
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
|
|
else
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
|
|
fi
|
|
|
|
- name: Fetch trusted base script
|
|
# Only the self-contained sandbox script is needed on the trusted host;
|
|
# PR code is checked out inside Docker. A full actions/checkout of this
|
|
# large repo stalled on the self-hosted runner before the agent ever
|
|
# ran, so fetch just the one trusted file via the API instead. The ref
|
|
# is pinned to the trusted base/dispatch commit, never PR head.
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TRUSTED_REF: ${{ github.event.pull_request.base.sha || github.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p .github/scripts
|
|
gh api \
|
|
-H 'Accept: application/vnd.github.raw' \
|
|
"repos/$GITHUB_REPOSITORY/contents/.github/scripts/agent-pr-explore-sandbox.sh?ref=$TRUSTED_REF" \
|
|
> .github/scripts/agent-pr-explore-sandbox.sh
|
|
chmod +x .github/scripts/agent-pr-explore-sandbox.sh
|
|
echo "Fetched trusted agent-pr-explore-sandbox.sh at $TRUSTED_REF"
|
|
|
|
- name: Resolve PR metadata
|
|
id: pr
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
EVENT_PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number }}
|
|
run: |
|
|
set -euo pipefail
|
|
if ! [[ "$EVENT_PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Invalid PR number: $EVENT_PR_NUMBER"
|
|
exit 1
|
|
fi
|
|
|
|
state="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state --jq '.state')"
|
|
draft="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')"
|
|
author="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')"
|
|
head_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq '.headRefOid')"
|
|
head_repo="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRepositoryOwner,headRepository --jq '.headRepositoryOwner.login + "/" + .headRepository.name')"
|
|
base_repo="$GITHUB_REPOSITORY"
|
|
base_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json baseRefOid --jq '.baseRefOid')"
|
|
|
|
if [ "$state" != "OPEN" ]; then
|
|
echo "::error::Refusing to explore PR $EVENT_PR_NUMBER because state is $state."
|
|
exit 1
|
|
fi
|
|
if [ "$draft" != "false" ]; then
|
|
echo "::error::Refusing to explore draft PR $EVENT_PR_NUMBER."
|
|
exit 1
|
|
fi
|
|
if [ "$base_repo" != "$GITHUB_REPOSITORY" ]; then
|
|
echo "::error::Unexpected base repo $base_repo."
|
|
exit 1
|
|
fi
|
|
if ! [[ "$head_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "::error::Invalid PR SHA metadata."
|
|
exit 1
|
|
fi
|
|
|
|
{
|
|
echo "number=$EVENT_PR_NUMBER"
|
|
echo "author=$author"
|
|
echo "head_sha=$head_sha"
|
|
echo "head_repo=$head_repo"
|
|
echo "base_sha=$base_sha"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Run expect against Docker-isolated PR app
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
|
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
|
HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
|
BASE_REPO: ${{ github.repository }}
|
|
BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
|
OD_SANDBOX_CPUS: "4"
|
|
OD_SANDBOX_MEMORY: "8g"
|
|
OD_SANDBOX_READY_TIMEOUT_SECONDS: "900"
|
|
OD_EXPECT_TIMEOUT_SECONDS: "1200"
|
|
OD_EXPECT_CONTEXT_MAX_BYTES: "120000"
|
|
OD_TRACE_R2_UPLOAD: "1"
|
|
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID || secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY || secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
|
R2_BUCKET: ${{ secrets.R2_BUCKET || secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
|
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN || secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT || secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
|
run: .github/scripts/agent-pr-explore-sandbox.sh
|
|
|
|
- name: Upload sandbox artifacts
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: agent-pr-explore-sandbox-${{ steps.pr.outputs.number }}-${{ steps.pr.outputs.head_sha }}
|
|
# The trace zips (~28MB) and videos are already published to R2; keep
|
|
# them out of the GitHub artifact so it stays small (report + logs).
|
|
path: |
|
|
${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/
|
|
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.zip
|
|
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.webm
|
|
if-no-files-found: warn
|
|
retention-days: 7
|
|
|
|
- name: Comment exploration report
|
|
if: ${{ always() && !inputs.skip_comment }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
|
PR_AUTHOR: ${{ steps.pr.outputs.author }}
|
|
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
report="$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md"
|
|
if [ ! -s "$report" ]; then
|
|
echo "No agent exploration report was produced; skipping PR comment."
|
|
exit 0
|
|
fi
|
|
|
|
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
|
body_file="$(mktemp)"
|
|
{
|
|
cat "$report"
|
|
echo
|
|
echo "$marker"
|
|
} > "$body_file"
|
|
|
|
comment_id="$(
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
|
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" \
|
|
| tail -n 1
|
|
)"
|
|
body="$(cat "$body_file")"
|
|
if [ -n "$comment_id" ]; then
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" -X PATCH -f body="$body" --silent
|
|
else
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent
|
|
fi
|
|
|
|
pr_author_lc="$(printf '%s' "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')"
|
|
case "$pr_author_lc" in
|
|
nettee|mrcfps|alchemistklk|siri-ray)
|
|
;;
|
|
*)
|
|
echo "PR author $PR_AUTHOR is not configured for inline agent reports; skipping inline review comment."
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
files_json="$(mktemp)"
|
|
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$files_json"
|
|
inline_target="$(
|
|
FILES_JSON="$files_json" node <<'NODE'
|
|
const fs = require("node:fs");
|
|
const pages = JSON.parse(fs.readFileSync(process.env.FILES_JSON, "utf8"));
|
|
const files = pages.flat();
|
|
|
|
function firstAddedLine(file) {
|
|
if (typeof file.patch !== "string") return null;
|
|
let newLine = null;
|
|
for (const line of file.patch.split("\n")) {
|
|
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
if (hunk) {
|
|
newLine = Number(hunk[1]);
|
|
continue;
|
|
}
|
|
if (newLine == null) continue;
|
|
if (line.startsWith("+") && !line.startsWith("+++")) return newLine;
|
|
if (!line.startsWith("-")) newLine += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (!file || file.status === "removed") continue;
|
|
const line = firstAddedLine(file);
|
|
if (line != null) {
|
|
process.stdout.write(JSON.stringify({ path: file.filename, line }));
|
|
process.exit(0);
|
|
}
|
|
}
|
|
NODE
|
|
)"
|
|
if [ -z "$inline_target" ]; then
|
|
echo "No added diff line was available for an inline agent report; skipping inline review comment."
|
|
exit 0
|
|
fi
|
|
|
|
inline_path="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(target.path)' "$inline_target")"
|
|
inline_line="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(String(target.line))' "$inline_target")"
|
|
inline_marker="<!-- agent-pr-explore-inline:${PR_NUMBER} -->"
|
|
inline_body_file="$(mktemp)"
|
|
{
|
|
cat "$report"
|
|
echo
|
|
echo "$inline_marker"
|
|
} > "$inline_body_file"
|
|
inline_body="$(cat "$inline_body_file")"
|
|
|
|
inline_comment_id="$(
|
|
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \
|
|
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$inline_marker\"))) | .id" \
|
|
| tail -n 1
|
|
)"
|
|
if [ -n "$inline_comment_id" ]; then
|
|
gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$inline_comment_id" -X PATCH -f body="$inline_body" --silent
|
|
else
|
|
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" \
|
|
-f body="$inline_body" \
|
|
-f commit_id="$HEAD_SHA" \
|
|
-f path="$inline_path" \
|
|
-F line="$inline_line" \
|
|
-f side=RIGHT \
|
|
--silent
|
|
fi
|
|
|
|
- name: React done on /explore command
|
|
if: ${{ success() && github.event_name == 'issue_comment' }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
COMMENT_ID: ${{ github.event.comment.id }}
|
|
run: gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=rocket --silent || true
|
|
|
|
- name: Report /explore command failure
|
|
if: ${{ failure() && github.event_name == 'issue_comment' }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
COMMENT_ID: ${{ github.event.comment.id }}
|
|
PR_NUMBER: ${{ github.event.issue.number }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
run: |
|
|
set -uo pipefail
|
|
# If a report was produced before the non-zero exit, the report step
|
|
# already posted it — leave it rather than clobbering useful output.
|
|
if [ -s "$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md" ]; then
|
|
echo "A report exists despite the non-zero exit; leaving it in place."
|
|
exit 0
|
|
fi
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content="-1" --silent || true
|
|
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
|
body="⚠️ Exploration run failed before producing a report — see [the run]($RUN_URL).
|
|
|
|
$marker"
|
|
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
|
|
if [ -n "$existing" ]; then
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
|
|
else
|
|
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
|
|
fi
|