Skip to content

first-run onboarding redesign #17359

first-run onboarding redesign

first-run onboarding redesign #17359

name: PR Visual Recap (fork, trusted + label-gated)

Check warning on line 1 in .github/workflows/pr-visual-recap-fork.yml

View workflow run for this annotation

GitHub Actions / PR Visual Recap (fork, trusted + label-gated)

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
# 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"