fix(content): curate slash command blocks with Labs #17372
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Visual Recap (fork, trusted + label-gated) | ||
|
Check warning on line 1 in .github/workflows/pr-visual-recap-fork.yml
|
||
| # Fork-safe variant of pr-visual-recap.yml. | ||
| # | ||
| # PURPOSE: Fork PRs cannot run the main workflow (pull_request runs without | ||
| # secrets, so publishing fails cleanly and is skipped). This workflow lets a | ||
| # trusted member/collaborator author get a recap automatically, and lets a | ||
| # MAINTAINER opt an outside fork PR in by applying the `recap` label after | ||
| # reviewing the diff for prompt-injection-shaped content. | ||
| # | ||
| # SECURITY MODEL: | ||
| # - Trigger: pull_request_target (has access to base-repo secrets). | ||
| # - Gate condition: PR is from a fork AND either the author is an active | ||
| # member of the base repository owner organization, has write-level access | ||
| # to the base repository, the author association is trusted (`OWNER`, | ||
| # `MEMBER`, or `COLLABORATOR`), OR this event is a fresh `recap` label | ||
| # application. Non-fork PRs are excluded — the main workflow covers them. | ||
| # - NO fork code is checked out or executed. We checkout only the BASE | ||
| # repository at the base ref. The diff is obtained by fetching the fork | ||
| # head ref as a remote ref (safe — fetching commits is not executing them) | ||
| # then running `git diff base...refs/recap/fork-head`. | ||
| # - The diff is attacker-controlled text fed as INPUT to the LLM; it is | ||
| # never executed. Mitigations: secret-scan (fail-closed), sensitive-path | ||
| # gate, and the prompt explicitly marks diff content as untrusted data. | ||
| # - To refresh after new fork commits, a maintainer must review the updated | ||
| # diff, then remove/reapply the `recap` label, unless the PR author has a | ||
| # trusted author association. A rerun only reuses the original event | ||
| # payload/SHA and is appropriate for transient failures on the same reviewed | ||
| # commit. | ||
| # - Concurrency: per-PR, cancel-in-progress (mirror of main workflow). | ||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize, reopened, ready_for_review, labeled] | ||
| permissions: | ||
| contents: read | ||
| concurrency: | ||
| group: pr-visual-recap-fork-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
| env: | ||
| VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }} | ||
| VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }} | ||
| VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }} | ||
| VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }} | ||
| jobs: | ||
| gate: | ||
| name: Gate (fork label check) | ||
| # The gate script already rejects same-repo PRs (pr-visual-recap.yml covers | ||
| # them), so skip the job rather than claim a runner to reach that verdict. | ||
| if: github.event.pull_request.head.repo.full_name != github.repository | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: read | ||
| issues: read | ||
| pull-requests: read | ||
| outputs: | ||
| run: ${{ steps.decide.outputs.run }} | ||
| agent: ${{ steps.decide.outputs.agent }} | ||
| steps: | ||
| - id: decide | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| # Presence-only signals — never expose secret VALUES to the gate. | ||
| HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }} | ||
| HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }} | ||
| HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }} | ||
| HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != '' }} | ||
| AGENT: ${{ env.VISUAL_RECAP_AGENT }} | ||
| VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }} | ||
| VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }} | ||
| VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| with: | ||
| script: | | ||
| const pr = context.payload.pull_request; | ||
| const reasons = []; | ||
| if (!pr) { reasons.push('no pull_request payload'); } | ||
| // This workflow is ONLY for fork PRs. Non-fork PRs are covered by | ||
| // the main pr-visual-recap.yml workflow (pull_request trigger). | ||
| const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name; | ||
| const isFork = pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY; | ||
| if (!isFork) { | ||
| reasons.push('not a fork PR — handled by pr-visual-recap.yml'); | ||
| } | ||
| // Trusted org members/collaborators can run automatically, even | ||
| // from personal forks. Outside contributors need a fresh `recap` | ||
| // label event for this head SHA; a stale label left on the PR is | ||
| // not enough after a new push. | ||
| const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; | ||
| const authorLogin = (pr && pr.user && pr.user.login || '').toLowerCase(); | ||
| let association = (pr && pr.author_association || '').toUpperCase(); | ||
| const seenAssociations = []; | ||
| const recordAssociation = (value) => { | ||
| const normalized = (value || '').toUpperCase(); | ||
| if (normalized && !seenAssociations.includes(normalized)) { | ||
| seenAssociations.push(normalized); | ||
| } | ||
| }; | ||
| recordAssociation(association); | ||
| if (pr && pr.number) { | ||
| // pull_request_target webhook author_association is unreliable — | ||
| // it can be absent, stale, or CONTRIBUTOR when MEMBER also | ||
| // applies. Cross-check REST resources owned by GitHub before | ||
| // deciding whether a member needs the manual recap label. | ||
| try { | ||
| const { data: issueDetails } = await github.rest.issues.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: pr.number, | ||
| }); | ||
| recordAssociation(issueDetails.author_association); | ||
| } catch (e) { | ||
| core.warning(`Could not fetch issue author_association via API (${e.message}); continuing with other association signals`); | ||
| } | ||
| try { | ||
| const { data: prDetails } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: pr.number, | ||
| }); | ||
| recordAssociation(prDetails.author_association); | ||
| } catch (e) { | ||
| core.warning(`Could not fetch PR author_association via API (${e.message}); continuing with other association signals`); | ||
| } | ||
| } | ||
| let isOrgMember = false; | ||
| let hasTrustedRepoPermission = false; | ||
| let repoPermission = ''; | ||
| if (authorLogin && context.repo.owner) { | ||
| try { | ||
| const { data: membership } = await github.rest.orgs.getMembershipForUser({ | ||
| org: context.repo.owner, | ||
| username: authorLogin, | ||
| }); | ||
| const state = (membership && membership.state || '').toLowerCase(); | ||
| const role = (membership && membership.role || 'member').toLowerCase(); | ||
| isOrgMember = state === 'active'; | ||
| if (isOrgMember) { | ||
| core.info(`PR author ${authorLogin} is an active ${context.repo.owner} org ${role}.`); | ||
| } | ||
| } catch (e) { | ||
| if (e.status === 404) { | ||
| core.info(`PR author ${authorLogin} is not an active ${context.repo.owner} org member according to GitHub API.`); | ||
| } else { | ||
| core.warning(`Could not verify ${context.repo.owner} org membership for ${authorLogin} (${e.message}); continuing with association signals`); | ||
| } | ||
| } | ||
| try { | ||
| const { data: permissions } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| username: authorLogin, | ||
| }); | ||
| repoPermission = (permissions && permissions.permission || '').toLowerCase(); | ||
| hasTrustedRepoPermission = ['admin', 'maintain', 'write'].includes(repoPermission); | ||
| core.info(`PR author ${authorLogin} repository permission: ${repoPermission || 'none'} (trusted=${hasTrustedRepoPermission}).`); | ||
| } catch (e) { | ||
| if (e.status === 404) { | ||
| core.info(`PR author ${authorLogin} has no explicit ${context.repo.owner}/${context.repo.repo} collaborator permission according to GitHub API.`); | ||
| } else { | ||
| core.warning(`Could not verify ${context.repo.owner}/${context.repo.repo} permission for ${authorLogin} (${e.message}); continuing with association signals`); | ||
| } | ||
| } | ||
| } | ||
| const hasTrustedAssociation = seenAssociations.some((value) => trustedAssociations.includes(value)); | ||
| const isTrustedAuthor = isOrgMember || hasTrustedRepoPermission || hasTrustedAssociation; | ||
| association = seenAssociations.find((value) => trustedAssociations.includes(value)) || (isOrgMember ? 'MEMBER' : association); | ||
| core.info(`PR author association: ${seenAssociations.join(', ') || 'empty'}; orgMember=${isOrgMember}; repoPermission=${repoPermission || 'unknown'} (trusted=${isTrustedAuthor})`); | ||
| const eventLabel = context.payload.label && context.payload.label.name; | ||
| const labels = (pr && pr.labels || []).map((l) => l.name); | ||
| const hasRecapLabel = labels.includes('recap'); | ||
| const freshRecapLabel = context.payload.action === 'labeled' && eventLabel === 'recap' && hasRecapLabel; | ||
| if (!isTrustedAuthor && !freshRecapLabel) { | ||
| reasons.push('external fork PR requires a maintainer to apply the recap label to the current head SHA'); | ||
| } | ||
| if (pr && pr.draft) reasons.push('draft PR'); | ||
| const login = authorLogin; | ||
| const botAuthors = ['dependabot[bot]', 'dependabot', 'renovate[bot]', 'renovate']; | ||
| if (botAuthors.includes(login)) reasons.push(`bot author (${login})`); | ||
| if (pr && pr.user && pr.user.type === 'Bot') reasons.push('bot author (type=Bot)'); | ||
| if (process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured'); | ||
| // Normalize + validate the agent. | ||
| const rawAgent = (process.env.AGENT || 'claude').toLowerCase(); | ||
| const agent = ['deepseek', 'kimi', 'moonshot', 'custom'].includes(rawAgent) ? 'openai-compatible' : rawAgent; | ||
| if (!['claude', 'codex', 'openai-compatible'].includes(agent)) { | ||
| reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`); | ||
| } else if (agent === 'codex') { | ||
| if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)'); | ||
| } else if (agent === 'claude') { | ||
| if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)'); | ||
| } else { | ||
| if (process.env.HAS_COMPATIBLE !== 'true') reasons.push('VISUAL_RECAP_API_KEY not configured (openai-compatible backend)'); | ||
| if (!(process.env.VISUAL_RECAP_MODEL || '').trim()) reasons.push('VISUAL_RECAP_MODEL is required (openai-compatible backend)'); | ||
| const baseUrl = process.env.VISUAL_RECAP_BASE_URL || ''; | ||
| try { | ||
| const parsed = new URL(baseUrl); | ||
| if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { | ||
| reasons.push('VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials'); | ||
| } | ||
| } catch { | ||
| reasons.push('VISUAL_RECAP_BASE_URL must be a valid http(s) URL'); | ||
| } | ||
| } | ||
| // Validate the model before it reaches the agent CLI. | ||
| const model = process.env.VISUAL_RECAP_MODEL || ''; | ||
| if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) { | ||
| reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`); | ||
| } | ||
| const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || 'auto').toLowerCase(); | ||
| if (!['auto', 'latest', 'repo'].includes(skillSource)) { | ||
| reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")'); | ||
| } | ||
| const usesRepoSkill = skillSource === 'repo'; | ||
| // Self-modifying guard: skip untrusted fork PRs if they touch the | ||
| // workflow, repo-pinned skill instructions, or any root agent config | ||
| // the runner loads. Evaluated from the trusted gate using the GitHub | ||
| // API — no fork code runs here. Trusted write actors may edit these | ||
| // files as reviewable content, and recapping them is useful signal. | ||
| if (pr && !isTrustedAuthor) { | ||
| try { | ||
| const files = await github.paginate(github.rest.pulls.listFiles, { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: pr.number, | ||
| per_page: 100, | ||
| }); | ||
| const isSensitive = (p) => | ||
| p === '.github/workflows/pr-visual-recap.yml' || | ||
| p === '.github/workflows/pr-visual-recap-fork.yml' || | ||
| (usesRepoSkill && /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p)) || | ||
| p.startsWith('.claude/') || | ||
| p === 'CLAUDE.md' || | ||
| p === 'AGENTS.md' || | ||
| p === '.mcp.json'; | ||
| const hits = files.map((f) => f.filename).filter(isSensitive); | ||
| if (hits.length) { | ||
| reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', …' : ''}) — skipping so untrusted PR diff never runs with secrets`); | ||
| } | ||
| } catch (e) { | ||
| // Fail CLOSED: if the file list can't be read, skip. | ||
| reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`); | ||
| } | ||
| } | ||
| const run = reasons.length === 0; | ||
| core.setOutput('run', run ? 'true' : 'false'); | ||
| core.setOutput('agent', agent); | ||
| if (run) { | ||
| core.info(`Fork visual recap will run (${agent}).`); | ||
| } else { | ||
| // Surface the skip reason as a run-summary annotation, not just a | ||
| // buried info log, so it's clear in the Actions UI why we skipped. | ||
| core.notice(`Fork visual recap skipped: ${reasons.join('; ')}`); | ||
| } | ||
| recap: | ||
| name: Generate visual recap (fork) | ||
| needs: gate | ||
| if: needs.gate.outputs.run == 'true' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| checks: write | ||
| contents: read | ||
| issues: write | ||
| pull-requests: write | ||
| env: | ||
| PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || 'https://plan.agent-native.com' }} | ||
| PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }} | ||
| GH_TOKEN: ${{ github.token }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| PR_STATE: ${{ github.event.pull_request.state }} | ||
| PR_MERGED: ${{ github.event.pull_request.merged }} | ||
| PR_MERGED_AT: ${{ github.event.pull_request.merged_at }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }} | ||
| VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }} | ||
| VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }} | ||
| VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }} | ||
| VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }} | ||
| steps: | ||
| # Checkout the BASE repository at the BASE ref ONLY. | ||
| # - `repository:` is pinned to GITHUB_REPOSITORY (the base repo) to rule | ||
| # out any scenario where the expression resolves to the fork. | ||
| # - `ref:` is the PR base branch, not the fork head — no fork code on disk. | ||
| # - `persist-credentials: false` keeps the token out of .git/config. | ||
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| repository: ${{ github.repository }} | ||
| ref: ${{ github.event.pull_request.base.ref }} | ||
| fetch-depth: 0 | ||
| persist-credentials: false | ||
| # Fetch the fork head as a read-only remote ref so we can diff against it. | ||
| # Fetching commits is safe — we are not executing or installing from them. | ||
| # The ref lands at refs/recap/fork-head (not merged into any branch). | ||
| - name: Fetch fork head ref | ||
| env: | ||
| PR_NUMBER_ENV: ${{ github.event.pull_request.number }} | ||
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | ||
| run: | | ||
| set -euo pipefail | ||
| git fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/fork-head" | ||
| # Verify the fetched tip matches the event's head SHA so a race | ||
| # between label application and a new push cannot swap the diff. | ||
| FETCHED_SHA="$(git rev-parse refs/recap/fork-head)" | ||
| if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then | ||
| echo "FATAL: fetched fork head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to prevent TOCTOU execution of unreviewed commits" | ||
| exit 1 | ||
| fi | ||
| # Export the two endpoints for the collect-diff step. | ||
| echo "FORK_BASE_SHA=${BASE_SHA}" >> "$GITHUB_ENV" | ||
| echo "FORK_HEAD_SHA=${FETCHED_SHA}" >> "$GITHUB_ENV" | ||
| # Dogfood local source inside this monorepo, else the published package. | ||
| - name: Resolve recap CLI | ||
| id: cli | ||
| env: | ||
| RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }} | ||
| run: | | ||
| if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then | ||
| echo "RECAP_CLI=pnpm exec tsx packages/core/src/cli/index.ts" >> "$GITHUB_ENV" | ||
| echo "CODE_CLI=pnpm exec tsx packages/core/src/cli/index.ts" >> "$GITHUB_ENV" | ||
| echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV" | ||
| echo "local=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "local=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 | ||
| if: steps.cli.outputs.local == 'true' | ||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | ||
| with: | ||
| node-version: "22" | ||
| cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }} | ||
| - name: Install workspace (local source only) | ||
| if: steps.cli.outputs.local == 'true' | ||
| run: | | ||
| set -euo pipefail | ||
| pnpm install --frozen-lockfile --ignore-scripts | ||
| pnpm --filter '@agent-native/core...' build | ||
| - name: Install published recap CLI | ||
| if: steps.cli.outputs.local != 'true' | ||
| env: | ||
| RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }} | ||
| run: | | ||
| set -euo pipefail | ||
| VERSION="$RECAP_CLI_VERSION" | ||
| if [ "$VERSION" = "latest" ]; then | ||
| VERSION="$(npm view @agent-native/recap-cli@latest version)" | ||
| fi | ||
| for attempt in 1 2 3; do | ||
| if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then | ||
| break | ||
| fi | ||
| if [ "$attempt" = "3" ]; then exit 1; fi | ||
| sleep $((attempt * 10)) | ||
| done | ||
| echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV" | ||
| echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV" | ||
| echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV" | ||
| - name: Install OpenAI-compatible provider runtime | ||
| if: needs.gate.outputs.agent == 'openai-compatible' && steps.cli.outputs.local != 'true' | ||
| env: | ||
| CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || 'latest' }} | ||
| run: | | ||
| set -euo pipefail | ||
| CORE_VERSION="$CORE_CLI_VERSION" | ||
| if [ "$CORE_VERSION" = "latest" ]; then | ||
| CORE_VERSION="$(npm view @agent-native/core@latest version)" | ||
| fi | ||
| npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION" | ||
| echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV" | ||
| echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV" | ||
| - name: Start visual recap check | ||
| id: recap_check | ||
| continue-on-error: true | ||
| run: | | ||
| set -uo pipefail | ||
| $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | ||
| # Collect a bounded diff between the base and the fetched fork head. | ||
| # FORK_BASE_SHA / FORK_HEAD_SHA reference the trusted local-object-store | ||
| # refs — no fork code is involved in the diff collection itself. | ||
| - name: Collect bounded diff (fork) | ||
| id: diff | ||
| run: | | ||
| set -euo pipefail | ||
| $RECAP_CLI recap collect-diff --base "$FORK_BASE_SHA" --head "refs/recap/fork-head" --out recap.diff --stat recap.stat | ||
| - name: Probe plan-app auth | ||
| id: auth_probe | ||
| if: steps.diff.outputs.tiny != 'true' | ||
| continue-on-error: true | ||
| run: | | ||
| set -uo pipefail | ||
| HTTP_STATUS=$(node -e ' | ||
| const https = require("https"); | ||
| const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com"); | ||
| 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(); }); | ||
| req.on("error", () => process.stdout.write("0")); | ||
| req.end(JSON.stringify({ planId: "__probe__" })); | ||
| ' 2>/dev/null || echo "0") | ||
| if [ "$HTTP_STATUS" = "401" ]; then | ||
| echo "auth_failed=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "auth_failed=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| - name: Probe plan-app route health | ||
| id: route_health | ||
| if: steps.diff.outputs.tiny != 'true' | ||
| continue-on-error: true | ||
| run: | | ||
| set -uo pipefail | ||
| # Pre-publish health gate: confirm the plan app's recap action routes | ||
| # are actually deployed BEFORE the agent runs. A 404 from | ||
| # create-visual-recap (POST) or get-plan-blocks (GET) means the | ||
| # plan-app deploy has not propagated yet (the client is ahead of the | ||
| # deployed server). Say that plainly here instead of letting the agent | ||
| # run and then fail confusingly at publish time. A 401 or 200 is | ||
| # healthy — the route exists, it just rejected/accepted the probe. | ||
| probe_status() { | ||
| ROUTE="$1" METHOD="$2" node -e ' | ||
| const https = require("https"); | ||
| const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com"; | ||
| const url = new URL(process.env.ROUTE, base); | ||
| if (process.env.METHOD === "GET") url.searchParams.set("format", "reference"); | ||
| 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(); }); | ||
| req.on("error", () => process.stdout.write("0")); | ||
| req.on("timeout", () => { process.stdout.write("0"); req.destroy(); }); | ||
| if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); } | ||
| ' 2>/dev/null || echo "0" | ||
| } | ||
| CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)" | ||
| BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)" | ||
| REASON="" | ||
| if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then | ||
| 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." | ||
| echo "::error::$REASON" | ||
| echo "unhealthy=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "unhealthy=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| { | ||
| echo 'reason<<__RECAP_ROUTE_HEALTH_EOF__' | ||
| echo "$REASON" | ||
| echo '__RECAP_ROUTE_HEALTH_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| # Secret-scan the fork diff BEFORE the agent ever sees it. | ||
| # Fail CLOSED: any scanner error treats the diff as suppressed. | ||
| - name: Secret scan | ||
| id: scan | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' | ||
| run: | | ||
| set -uo pipefail | ||
| if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then | ||
| SCAN_JSON='{"suppressed":true,"reason":"secret scan failed to run; failing closed"}' | ||
| fi | ||
| { | ||
| SCAN_DELIM="$(openssl rand -hex 16)" | ||
| echo "json<<${SCAN_DELIM}" | ||
| echo "$SCAN_JSON" | ||
| echo "${SCAN_DELIM}" | ||
| } >> "$GITHUB_OUTPUT" | ||
| SUPPRESSED=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}' "$SCAN_JSON") | ||
| echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT" | ||
| - name: Read previous plan id | ||
| id: prev | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' | ||
| continue-on-error: true | ||
| run: | | ||
| set -euo pipefail | ||
| PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")" | ||
| echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT" | ||
| - name: Fetch plan block reference | ||
| id: block_reference | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| continue-on-error: true | ||
| run: | | ||
| set -uo pipefail | ||
| if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then | ||
| echo "ok=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "ok=false" >> "$GITHUB_OUTPUT" | ||
| { | ||
| echo 'summary<<__RECAP_BLOCK_REFERENCE_EOF__' | ||
| echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX." | ||
| echo '__RECAP_BLOCK_REFERENCE_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| cat > recap-blocks.md <<'EOF' | ||
| Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting. | ||
| EOF | ||
| fi | ||
| # Build the recap prompt with --fork-pr so the agent is explicitly told | ||
| # that the diff is attacker-controlled untrusted text, not instructions. | ||
| - name: Build recap prompt | ||
| id: prompt | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| env: | ||
| PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }} | ||
| DIFF_HUGE: ${{ steps.diff.outputs.huge }} | ||
| run: | | ||
| set -euo pipefail | ||
| 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" --fork-pr --out recap-prompt.md) | ||
| if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi | ||
| if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi | ||
| $RECAP_CLI recap build-prompt "${ARGS[@]}" | ||
| - name: Run agent (Claude Code) | ||
| id: claude | ||
| if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| continue-on-error: true | ||
| env: | ||
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| run: | | ||
| set -uo pipefail | ||
| CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)" | ||
| CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json) | ||
| CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}") | ||
| rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log | ||
| run_claude() { | ||
| set +e | ||
| npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log | ||
| CLAUDE_STATUS="$?" | ||
| set -e | ||
| echo "$CLAUDE_STATUS" > claude-exit-code.txt | ||
| } | ||
| run_claude | ||
| # A clean agent exit WITHOUT recap-source.json is the strongest | ||
| # "retry me" signal — the deterministic publisher needs that file, and | ||
| # the agent occasionally finishes a turn without writing it. Retry once. | ||
| if [ ! -s recap-source.json ]; then | ||
| 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 | ||
| echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry." | ||
| else | ||
| echo "::warning::recap-source.json missing after the agent run; retrying the agent once." | ||
| sleep 5 | ||
| run_claude | ||
| fi | ||
| fi | ||
| - name: Run agent (Codex) | ||
| id: codex | ||
| if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| continue-on-error: true | ||
| env: | ||
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | ||
| run: | | ||
| set -uo pipefail | ||
| printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true | ||
| CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check) | ||
| if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi | ||
| case "${VISUAL_RECAP_REASONING:-}" in | ||
| none|minimal|low|medium|high|xhigh) | ||
| CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;; | ||
| "") ;; | ||
| *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;; | ||
| esac | ||
| rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log | ||
| run_codex() { | ||
| set +e | ||
| npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl | ||
| CODEX_STATUS="${PIPESTATUS[0]}" | ||
| set -e | ||
| echo "$CODEX_STATUS" > codex-exit-code.txt | ||
| } | ||
| run_codex | ||
| # Retry once if the agent exited without writing recap-source.json | ||
| # (see the Claude step) — the publisher needs that file. | ||
| if [ ! -s recap-source.json ]; then | ||
| 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 | ||
| echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry." | ||
| else | ||
| echo "::warning::recap-source.json missing after the agent run; retrying the agent once." | ||
| sleep 5 | ||
| run_codex | ||
| fi | ||
| fi | ||
| - name: Run agent (OpenAI-compatible) | ||
| id: openai_compatible | ||
| if: needs.gate.outputs.agent == 'openai-compatible' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| continue-on-error: true | ||
| env: | ||
| OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }} | ||
| OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }} | ||
| AGENT_ENGINE: ai-sdk:openai | ||
| AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }} | ||
| AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json | ||
| AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source | ||
| run: | | ||
| set -uo pipefail | ||
| rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log | ||
| run_openai_compatible() { | ||
| set +e | ||
| $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log | ||
| OPENAI_COMPATIBLE_STATUS="$?" | ||
| set -e | ||
| echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt | ||
| } | ||
| run_openai_compatible | ||
| if [ ! -s recap-source.json ]; then | ||
| 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 | ||
| echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry." | ||
| else | ||
| echo "::warning::recap-source.json missing after the agent run; retrying the agent once." | ||
| sleep 5 | ||
| run_openai_compatible | ||
| fi | ||
| fi | ||
| - name: Check recap source | ||
| id: source_status | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| env: | ||
| RECAP_AGENT: ${{ needs.gate.outputs.agent }} | ||
| run: | | ||
| set -uo pipefail | ||
| if [ -s recap-source.json ]; then | ||
| echo "ready=true" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| case "$RECAP_AGENT" in | ||
| codex) AGENT_LABEL="Codex" ;; | ||
| claude) AGENT_LABEL="Claude" ;; | ||
| openai-compatible) AGENT_LABEL="OpenAI-compatible agent" ;; | ||
| *) AGENT_LABEL="Recap agent" ;; | ||
| esac | ||
| if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then | ||
| case "$RECAP_AGENT" in | ||
| codex) REASON="Codex could not author recap-source.json because the OpenAI API project's quota/budget is exhausted. Add API credits or raise that project's monthly budget, then rerun the workflow." ;; | ||
| claude) REASON="Claude could not author recap-source.json because the Anthropic provider quota is exhausted. Restore its quota or billing, then rerun the workflow." ;; | ||
| *) REASON="$AGENT_LABEL could not author recap-source.json because its provider quota was exceeded." ;; | ||
| esac | ||
| elif grep -Eiq -- 'invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log claude-result.json claude-stderr.log openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then | ||
| REASON="$AGENT_LABEL could not author recap-source.json because provider authentication failed." | ||
| else | ||
| REASON="$AGENT_LABEL did not produce recap-source.json before source authoring completed." | ||
| fi | ||
| printf '%s\n' "$REASON" > recap-url-reason.txt | ||
| echo "ready=false" >> "$GITHUB_OUTPUT" | ||
| { | ||
| echo 'reason<<__RECAP_SOURCE_REASON_EOF__' | ||
| echo "$REASON" | ||
| echo '__RECAP_SOURCE_REASON_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| echo "::warning::$REASON Skipping deterministic publish." | ||
| - name: Publish recap source | ||
| id: publish | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' && steps.source_status.outputs.ready == 'true' | ||
| continue-on-error: true | ||
| env: | ||
| PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }} | ||
| run: | | ||
| set -uo pipefail | ||
| 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") | ||
| if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi | ||
| ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER") | ||
| if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then | ||
| ARGS+=(--source-pr-state merged) | ||
| elif [ -n "${PR_STATE:-}" ]; then | ||
| ARGS+=(--source-pr-state "$PR_STATE") | ||
| fi | ||
| if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi | ||
| $RECAP_CLI recap publish "${ARGS[@]}" | ||
| - name: Build one-shot recap repair prompt | ||
| id: repair_prompt | ||
| if: steps.publish.outputs.repairable == 'true' | ||
| run: | | ||
| set -euo pipefail | ||
| cp recap-source.json recap-source.initial.json | ||
| $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md | ||
| - name: Repair recap source (Claude Code) | ||
| id: claude_repair | ||
| if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'claude' | ||
| continue-on-error: true | ||
| env: | ||
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | ||
| run: | | ||
| set -uo pipefail | ||
| CLAUDE_ALLOWED_TOOLS="Read,Write" | ||
| CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json) | ||
| CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}") | ||
| rm -f claude-repair-result.json claude-repair-stderr.log | ||
| set +e | ||
| npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log | ||
| CLAUDE_REPAIR_STATUS="$?" | ||
| set -e | ||
| echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt | ||
| if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi | ||
| - name: Repair recap source (Codex) | ||
| id: codex_repair | ||
| if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'codex' | ||
| continue-on-error: true | ||
| env: | ||
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | ||
| run: | | ||
| set -uo pipefail | ||
| printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true | ||
| CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check) | ||
| if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi | ||
| case "${VISUAL_RECAP_REASONING:-}" in | ||
| none|minimal|low|medium|high|xhigh) | ||
| CODEX_ARGS+=(-c "model_reasoning_effort=\"$VISUAL_RECAP_REASONING\"") ;; | ||
| "") ;; | ||
| *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;; | ||
| esac | ||
| rm -f codex-repair-events.jsonl codex-repair-stderr.log | ||
| set +e | ||
| npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl | ||
| CODEX_REPAIR_STATUS="${PIPESTATUS[0]}" | ||
| set -e | ||
| echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt | ||
| if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi | ||
| - name: Repair recap source (OpenAI-compatible) | ||
| id: openai_compatible_repair | ||
| if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'openai-compatible' | ||
| continue-on-error: true | ||
| env: | ||
| OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }} | ||
| OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }} | ||
| AGENT_ENGINE: ai-sdk:openai | ||
| AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }} | ||
| AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json | ||
| AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source | ||
| run: | | ||
| set -uo pipefail | ||
| rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log | ||
| set +e | ||
| $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log | ||
| OPENAI_COMPATIBLE_REPAIR_STATUS="$?" | ||
| set -e | ||
| echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt | ||
| if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi | ||
| - name: Validate repaired recap source | ||
| id: repaired_source | ||
| if: steps.publish.outputs.repairable == 'true' | ||
| env: | ||
| REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }} | ||
| run: | | ||
| set -uo pipefail | ||
| REPAIR_REASON="" | ||
| if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then | ||
| echo "ok=false" >> "$GITHUB_OUTPUT" | ||
| REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published." | ||
| else | ||
| VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)" | ||
| REPAIR_OK="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$VALIDATION_JSON")" | ||
| REPAIR_REASON="$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}' "$VALIDATION_JSON")" | ||
| echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| if [ -n "$REPAIR_REASON" ]; then | ||
| echo "$REPAIR_REASON" > recap-url-reason.txt | ||
| fi | ||
| { | ||
| echo 'reason<<__RECAP_REPAIR_REASON_EOF__' | ||
| echo "$REPAIR_REASON" | ||
| echo '__RECAP_REPAIR_REASON_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| - name: Publish repaired recap source | ||
| id: publish_repair | ||
| if: steps.repaired_source.outputs.ok == 'true' | ||
| continue-on-error: true | ||
| env: | ||
| PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }} | ||
| run: | | ||
| set -uo pipefail | ||
| 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") | ||
| if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi | ||
| ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER") | ||
| if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then | ||
| ARGS+=(--source-pr-state merged) | ||
| elif [ -n "${PR_STATE:-}" ]; then | ||
| ARGS+=(--source-pr-state "$PR_STATE") | ||
| fi | ||
| if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi | ||
| $RECAP_CLI recap publish "${ARGS[@]}" | ||
| - name: Read plan URL | ||
| id: url | ||
| if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| run: | | ||
| set -uo pipefail | ||
| PLAN_URL="" | ||
| URL_REASON="" | ||
| if [ -f recap-url.txt ]; then | ||
| PLAN_URL="$(tr -d '\r\n' < recap-url.txt | tr -d ' ')" | ||
| elif [ -f recap-url-reason.txt ]; then | ||
| URL_REASON="$(cat recap-url-reason.txt)" | ||
| else | ||
| URL_REASON="recap-url.txt was not created." | ||
| fi | ||
| if [ -z "$URL_REASON" ]; then | ||
| URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<'NODE' | ||
| const emit = (value) => process.stdout.write(JSON.stringify(value)); | ||
| try { | ||
| const raw = process.env.PLAN_URL || ""; | ||
| if (!raw) { | ||
| emit({ url: "", reason: "recap-url.txt was empty" }); | ||
| process.exit(0); | ||
| } | ||
| const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com"); | ||
| const parsed = /^https?:\/\//i.test(raw) | ||
| ? new URL(raw) | ||
| : new URL(raw, trusted); | ||
| if (parsed.origin !== trusted.origin) { | ||
| emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` }); | ||
| process.exit(0); | ||
| } | ||
| const base = trusted.pathname.replace(/\/$/, ""); | ||
| const paths = [parsed.pathname]; | ||
| if (base && parsed.pathname.startsWith(`${base}/`)) { | ||
| paths.push(parsed.pathname.slice(base.length) || "/"); | ||
| } | ||
| for (const path of paths) { | ||
| const match = path.match(/^\/(?:plans|recaps)\/([A-Za-z0-9_-]+)\/?$/); | ||
| if (match) { | ||
| emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" }); | ||
| process.exit(0); | ||
| } | ||
| } | ||
| emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" }); | ||
| } catch { | ||
| emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" }); | ||
| } | ||
| NODE | ||
| ) | ||
| CANONICAL_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}' "$URL_RESULT") | ||
| 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") | ||
| else | ||
| CANONICAL_URL="" | ||
| fi | ||
| if [ -n "$CANONICAL_URL" ]; then | ||
| echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| { | ||
| echo 'reason<<__RECAP_URL_REASON_EOF__' | ||
| echo "$URL_REASON" | ||
| echo '__RECAP_URL_REASON_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| - name: Summarize agent failure | ||
| id: agent_summary | ||
| if: steps.url.outputs.ok != 'true' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true' | ||
| continue-on-error: true | ||
| env: | ||
| RECAP_AGENT: ${{ needs.gate.outputs.agent }} | ||
| RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == 'success' }} | ||
| RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }} | ||
| RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }} | ||
| run: | | ||
| set -uo pipefail | ||
| RESULT=claude-result.json | ||
| STDERR=claude-stderr.log | ||
| EXIT_CODE=claude-exit-code.txt | ||
| if [ "$RECAP_AGENT" = "codex" ]; then | ||
| RESULT=codex-events.jsonl | ||
| STDERR=codex-stderr.log | ||
| EXIT_CODE=codex-exit-code.txt | ||
| elif [ "$RECAP_AGENT" = "openai-compatible" ]; then | ||
| RESULT=openai-compatible-result.txt | ||
| STDERR=openai-compatible-stderr.log | ||
| EXIT_CODE=openai-compatible-exit-code.txt | ||
| fi | ||
| if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then | ||
| RESULT=claude-repair-result.json | ||
| STDERR=claude-repair-stderr.log | ||
| EXIT_CODE=claude-repair-exit-code.txt | ||
| if [ "$RECAP_AGENT" = "codex" ]; then | ||
| RESULT=codex-repair-events.jsonl | ||
| STDERR=codex-repair-stderr.log | ||
| EXIT_CODE=codex-repair-exit-code.txt | ||
| elif [ "$RECAP_AGENT" = "openai-compatible" ]; then | ||
| RESULT=openai-compatible-repair-result.txt | ||
| STDERR=openai-compatible-repair-stderr.log | ||
| EXIT_CODE=openai-compatible-repair-exit-code.txt | ||
| fi | ||
| fi | ||
| 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 '{}')" | ||
| SUMMARY="$(node -e 'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}' "$SUMMARY_JSON")" | ||
| if [ -n "$SUMMARY" ]; then | ||
| { | ||
| echo 'summary<<__RECAP_AGENT_SUMMARY_EOF__' | ||
| echo "$SUMMARY" | ||
| echo '__RECAP_AGENT_SUMMARY_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then | ||
| { | ||
| echo 'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__' | ||
| echo "$RECAP_BLOCK_REFERENCE_SUMMARY" | ||
| echo '__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then | ||
| { | ||
| echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__' | ||
| echo "$RECAP_PUBLISH_REASON" | ||
| echo '__RECAP_PUBLISH_SUMMARY_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| fi | ||
| - name: Attach usage | ||
| if: steps.url.outputs.ok == 'true' | ||
| continue-on-error: true | ||
| env: | ||
| PLAN_URL: ${{ steps.url.outputs.plan_url }} | ||
| RECAP_AGENT: ${{ needs.gate.outputs.agent }} | ||
| RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == 'success' }} | ||
| run: | | ||
| set -uo pipefail | ||
| RESULT=claude-result.json | ||
| if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi | ||
| if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi | ||
| if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then | ||
| RESULT=claude-repair-result.json | ||
| if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi | ||
| if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi | ||
| fi | ||
| 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 | ||
| - name: Cache Playwright browsers | ||
| if: steps.url.outputs.ok == 'true' | ||
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | ||
| with: | ||
| path: ~/.cache/ms-playwright | ||
| key: playwright-1-${{ runner.os }} | ||
| - name: Screenshot + upload | ||
| id: shot | ||
| if: steps.url.outputs.ok == 'true' | ||
| continue-on-error: true | ||
| env: | ||
| PLAN_URL: ${{ steps.url.outputs.plan_url }} | ||
| run: | | ||
| set -uo pipefail | ||
| if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then | ||
| "$RECAP_PLAYWRIGHT" install --with-deps chromium || true | ||
| elif command -v pnpm >/dev/null 2>&1; then | ||
| pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true | ||
| else | ||
| npx -y playwright@1 install --with-deps chromium || true | ||
| fi | ||
| IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" | ||
| 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 '{}')" | ||
| 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 '{}')" | ||
| for SHOT_LABEL in light dark; do | ||
| if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi | ||
| 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)}`);' | ||
| done | ||
| IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$LIGHT_SHOT_JSON") | ||
| DARK_IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}' "$DARK_SHOT_JSON") | ||
| SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<'NODE' | ||
| const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } }; | ||
| const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]]; | ||
| const hasAllImages = shots.every(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim()); | ||
| const reasons = shots.flatMap(([label, shot]) => { | ||
| if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`]; | ||
| if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`]; | ||
| return []; | ||
| }); | ||
| process.stdout.write(JSON.stringify({ ok: hasAllImages, reason: hasAllImages ? "" : reasons.join("; ").slice(0, 1000) })); | ||
| NODE | ||
| ) | ||
| SHOT_OK=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}' "$SHOT_STATUS") | ||
| SHOT_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}' "$SHOT_STATUS") | ||
| if [ "$SHOT_OK" != "true" ]; then | ||
| echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON" | ||
| fi | ||
| echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT" | ||
| echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT" | ||
| echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT" | ||
| echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT" | ||
| { | ||
| echo 'shot_reason<<__RECAP_SHOT_REASON_EOF__' | ||
| echo "$SHOT_REASON" | ||
| echo '__RECAP_SHOT_REASON_EOF__' | ||
| } >> "$GITHUB_OUTPUT" | ||
| if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi | ||
| - name: Upload recap screenshot artifact | ||
| if: steps.shot.outputs.captured == 'true' | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: pr-visual-recap-fork-${{ github.event.pull_request.number }} | ||
| path: | | ||
| recap.png | ||
| recap-dark.png | ||
| if-no-files-found: ignore | ||
| retention-days: 14 | ||
| - name: Upload recap source artifact | ||
| if: always() && !cancelled() | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| # recap-source.json + the agent transcript (claude-result.json / | ||
| # codex-events.jsonl + stderr) are the only window into WHAT the agent | ||
| # did when a publish fails (no plan URL) — INCLUDING the case where it | ||
| # finished without writing recap-source.json at all. The sticky comment | ||
| # only shows the screenshot, so without these a failed recap is | ||
| # undebuggable. Uploaded on success + failure; tolerant when absent. | ||
| name: pr-visual-recap-source-fork-${{ github.event.pull_request.number }} | ||
| path: | | ||
| recap-source.json | ||
| recap-source.initial.json | ||
| recap-url-reason.txt | ||
| recap-repair-prompt.md | ||
| claude-result.json | ||
| claude-stderr.log | ||
| claude-repair-result.json | ||
| claude-repair-stderr.log | ||
| claude-repair-exit-code.txt | ||
| codex-events.jsonl | ||
| codex-stderr.log | ||
| codex-repair-events.jsonl | ||
| codex-repair-stderr.log | ||
| codex-repair-exit-code.txt | ||
| openai-compatible-result.txt | ||
| openai-compatible-usage.json | ||
| openai-compatible-stderr.log | ||
| openai-compatible-repair-result.txt | ||
| openai-compatible-repair-usage.json | ||
| openai-compatible-repair-stderr.log | ||
| openai-compatible-repair-exit-code.txt | ||
| if-no-files-found: ignore | ||
| retention-days: 14 | ||
| - name: Upsert sticky comment | ||
| if: always() && !cancelled() && steps.diff.outputs.tiny != 'true' | ||
| continue-on-error: true | ||
| env: | ||
| PLAN_URL: ${{ steps.url.outputs.plan_url }} | ||
| RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }} | ||
| RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }} | ||
| RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }} | ||
| RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }} | ||
| RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }} | ||
| SUPPRESSED: ${{ steps.scan.outputs.suppressed }} | ||
| SUPPRESSED_JSON: ${{ steps.scan.outputs.json }} | ||
| DIFF_HUGE: ${{ steps.diff.outputs.huge }} | ||
| DIFF_TINY: ${{ steps.diff.outputs.tiny }} | ||
| PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }} | ||
| RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }} | ||
| RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }} | ||
| # Prefer the route-health diagnostic when the plan app routes are not | ||
| # yet deployed so the comment explains the 404 instead of a generic | ||
| # "recap-url.txt was not created" message. | ||
| RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }} | ||
| run: | | ||
| set -euo pipefail | ||
| $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA" | ||
| - name: Complete visual recap check | ||
| if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != '' | ||
| continue-on-error: true | ||
| env: | ||
| CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }} | ||
| PLAN_OK: ${{ steps.url.outputs.ok }} | ||
| PLAN_URL: ${{ steps.url.outputs.plan_url }} | ||
| SUPPRESSED: ${{ steps.scan.outputs.suppressed }} | ||
| SUPPRESSED_JSON: ${{ steps.scan.outputs.json }} | ||
| DIFF_HUGE: ${{ steps.diff.outputs.huge }} | ||
| DIFF_TINY: ${{ steps.diff.outputs.tiny }} | ||
| RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }} | ||
| RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.source_status.outputs.reason || steps.url.outputs.reason }} | ||
| run: | | ||
| set -uo pipefail | ||
| $RECAP_CLI recap check complete \ | ||
| --check-run-id "$CHECK_RUN_ID" \ | ||
| --plan-ok "$PLAN_OK" \ | ||
| --plan-url "$PLAN_URL" \ | ||
| --suppressed "$SUPPRESSED" \ | ||
| --suppressed-json "$SUPPRESSED_JSON" \ | ||
| --huge "$DIFF_HUGE" \ | ||
| --tiny "$DIFF_TINY" \ | ||
| --failure-summary "$RECAP_AGENT_SUMMARY" \ | ||
| --url-reason "$RECAP_URL_REASON" \ | ||
| --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | ||