From ce7e5290f4c2a4149986ba1ca0988ff676ec79b7 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 13 Jul 2026 13:12:28 -0400 Subject: [PATCH] fix: run configurable visual recaps with bash --- .changeset/friendly-runners-use-bash.md | 6 ++++ .../workflows/pr-visual-recap-reusable.yml | 3 ++ .github/workflows/pr-visual-recap.yml | 3 ++ .../core/src/cli/pr-visual-recap-workflow.ts | 5 ++-- packages/core/src/cli/recap.spec.ts | 4 +++ .../src/pr-visual-recap-workflow.spec.ts | 29 +++++++++++++++++++ .../recap-cli/src/pr-visual-recap-workflow.ts | 2 +- 7 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .changeset/friendly-runners-use-bash.md create mode 100644 packages/recap-cli/src/pr-visual-recap-workflow.spec.ts diff --git a/.changeset/friendly-runners-use-bash.md b/.changeset/friendly-runners-use-bash.md new file mode 100644 index 0000000000..fd240272f9 --- /dev/null +++ b/.changeset/friendly-runners-use-bash.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/recap-cli": patch +--- + +Run configurable PR Visual Recap jobs with Bash on every runner platform. diff --git a/.github/workflows/pr-visual-recap-reusable.yml b/.github/workflows/pr-visual-recap-reusable.yml index 50fd4ef380..a6e5d5fa09 100644 --- a/.github/workflows/pr-visual-recap-reusable.yml +++ b/.github/workflows/pr-visual-recap-reusable.yml @@ -331,6 +331,9 @@ jobs: if: needs.gate.outputs.run == 'true' runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }} timeout-minutes: 30 + defaults: + run: + shell: bash permissions: actions: write checks: write diff --git a/.github/workflows/pr-visual-recap.yml b/.github/workflows/pr-visual-recap.yml index ab2610e905..4cf2613d04 100644 --- a/.github/workflows/pr-visual-recap.yml +++ b/.github/workflows/pr-visual-recap.yml @@ -233,6 +233,9 @@ jobs: if: needs.gate.outputs.run == 'true' runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }} timeout-minutes: 30 + defaults: + run: + shell: bash permissions: actions: write checks: write diff --git a/packages/core/src/cli/pr-visual-recap-workflow.ts b/packages/core/src/cli/pr-visual-recap-workflow.ts index 5f7c5598bb..19757d58e1 100644 --- a/packages/core/src/cli/pr-visual-recap-workflow.ts +++ b/packages/core/src/cli/pr-visual-recap-workflow.ts @@ -1,2 +1,3 @@ -/** Compatibility re-export for the workflow bundled by the recap CLI package. */ -export { PR_VISUAL_RECAP_WORKFLOW_YML } from "@agent-native/recap-cli"; +/** Canonical PR Visual Recap workflow bundled by the CLI installer. */ +export const PR_VISUAL_RECAP_WORKFLOW_YML = + 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(//);\n const planIdMarker = planIdMatch ? `\\n\\n` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/ or /recaps/ URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n'; diff --git a/packages/core/src/cli/recap.spec.ts b/packages/core/src/cli/recap.spec.ts index 68c865ba43..b23853830c 100644 --- a/packages/core/src/cli/recap.spec.ts +++ b/packages/core/src/cli/recap.spec.ts @@ -2726,6 +2726,9 @@ describe("bundled PR visual recap workflow", () => { expect(PR_VISUAL_RECAP_WORKFLOW_YML).toContain( "runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}", ); + expect(PR_VISUAL_RECAP_WORKFLOW_YML).toContain( + " defaults:\n run:\n shell: bash", + ); expect(PR_VISUAL_RECAP_WORKFLOW_YML).toContain( "core.setOutput('runs_on', JSON.stringify(configuredRunner))", ); @@ -3267,6 +3270,7 @@ describe("reusable workflow file structure", () => { expect(content).toContain( "runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}", ); + expect(content).toContain(" defaults:\n run:\n shell: bash"); expect(content).toContain( "core.setOutput('runs_on', JSON.stringify(configuredRunner))", ); diff --git a/packages/recap-cli/src/pr-visual-recap-workflow.spec.ts b/packages/recap-cli/src/pr-visual-recap-workflow.spec.ts new file mode 100644 index 0000000000..1668437058 --- /dev/null +++ b/packages/recap-cli/src/pr-visual-recap-workflow.spec.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); + +describe("the recap installer workflow", () => { + it("keeps Bash semantics on configurable runners", () => { + expect(PR_VISUAL_RECAP_WORKFLOW_YML).toContain( + " defaults:\n run:\n shell: bash", + ); + }); + + it("bundles the canonical workflow byte for byte", () => { + const source = readFileSync( + path.join(repoRoot, ".github/workflows/pr-visual-recap.yml"), + "utf8", + ); + + expect(PR_VISUAL_RECAP_WORKFLOW_YML).toBe(source); + }); +}); diff --git a/packages/recap-cli/src/pr-visual-recap-workflow.ts b/packages/recap-cli/src/pr-visual-recap-workflow.ts index 8a95c28aa6..19757d58e1 100644 --- a/packages/recap-cli/src/pr-visual-recap-workflow.ts +++ b/packages/recap-cli/src/pr-visual-recap-workflow.ts @@ -1,3 +1,3 @@ /** Canonical PR Visual Recap workflow bundled by the CLI installer. */ export const PR_VISUAL_RECAP_WORKFLOW_YML = - 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(//);\n const planIdMarker = planIdMatch ? `\\n\\n` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/ or /recaps/ URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n'; + 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(//);\n const planIdMarker = planIdMatch ? `\\n\\n` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the publisher will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/ or /recaps/ URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n claude-result.json\n claude-stderr.log\n codex-events.jsonl\n codex-stderr.log\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';