Skip to content

chore(deps): bump release-drafter/release-drafter from 7.4.0 to 7.5.1 #42

chore(deps): bump release-drafter/release-drafter from 7.4.0 to 7.5.1

chore(deps): bump release-drafter/release-drafter from 7.4.0 to 7.5.1 #42

Workflow file for this run

name: PR Visual Recap
# Visual code review: a coding agent runs the repo's visual-recap skill over the
# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.
# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
permissions:
contents: read
concurrency:
group: pr-visual-recap-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }}
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
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
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 != '' }}
AGENT: ${{ env.VISUAL_RECAP_AGENT }}
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');
if (pr && pr.draft) reasons.push('draft PR');
if (pr && context.payload.action === 'closed' && !pr.merged) {
reasons.push('closed without merge');
}
// Fork PRs only receive repo secrets when the org/repo opts into
// GitHub's "Send secrets to workflows from pull requests" setting
// (common in private orgs that use forks heavily). Gate on secret
// availability, not fork-ness: run on forks that have the token,
// and skip — with an actionable hint — those that don't.
const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;
const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);
const isPrivate = !!(context.payload.repository && context.payload.repository.private);
if (isFork && process.env.HAS_PLAN !== 'true') {
reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);
}
const login = (pr && pr.user && pr.user.login || '').toLowerCase();
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 (!isFork && process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');
// Normalize + validate the agent so a mis-cased value can't pass the
// gate and then match neither agent step below.
const agent = (process.env.AGENT || 'claude').toLowerCase();
if (agent !== 'claude' && agent !== 'codex') {
reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude" or "codex")`);
} else if (agent === 'codex') {
if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');
} else {
if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');
}
// 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, evaluated in the trusted gate (runs NO
// PR-checked-out code): skip the ENTIRE job if the PR touches the
// repo-pinned skill instructions or any agent config the runner
// loads, so a PR can't rewrite what the agent loads and exfiltrate
// secrets. With the default bundled skill source, visual skill and
// recap workflow files are reviewed content, not instructions loaded
// by the runner.
// Keep this guard for forks AND all public-repo PRs: a fork or a
// public same-repo author could rewrite loaded instruction files
// (AGENTS.md/CLAUDE.md/.claude/.mcp.json) and exfiltrate the
// secret-backed agent run. Skip it ONLY for private-repo same-repo
// PRs, where the author is a trusted org member — a deliberate owner
// risk acceptance so legit instruction edits don't false-skip recaps.
if (pr && (isFork || !isPrivate)) {
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) =>
(usesRepoSkill && /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p)) ||
/(^|\/)\.claude\//.test(p) ||
/(^|\/)CLAUDE\.md$/.test(p) ||
/(^|\/)AGENTS\.md$/.test(p) ||
/(^|\/)\.mcp\.json$/.test(p);
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 code 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(`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(`Visual recap skipped: ${reasons.join('; ')}`);
}
// When skipping, upsert a sticky recap comment with a short skip
// line so the PR always explains why the recap job did not run.
if (!run && pr) {
try {
const MARKER = '<!-- pr-visual-recap -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const existing = comments.find(
(c) => c.user && c.user.type === 'Bot' && c.body && c.body.includes(MARKER)
);
const headShort = (process.env.HEAD_SHA || '').slice(0, 7);
const shaRef = headShort ? `\`${headShort}\`` : 'latest push';
const primaryReason = reasons.filter(
(r) => !r.startsWith('could not list PR files for the self-modifying guard')
)[0] || reasons[0] || 'skipped';
const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;
const baseBody = `${MARKER}\n### Visual recap — skipped\n\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;
const withoutPrev = (existing && existing.body ? existing.body : baseBody)
.split('\n')
.filter((l) => !/_Recap skipped for .+_$/.test(l.trim()))
.join('\n')
.trimEnd();
const updatedBody = `${withoutPrev}\n\n${skipLine}`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: updatedBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: updatedBody,
});
}
} catch (e) {
core.warning(`Could not update recap skip comment: ${e.message}`);
}
}
recap:
name: Generate visual recap
needs: gate
if: needs.gate.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
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_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:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
# This job runs an agent over untrusted PR diff; don't leave the token
# in .git/config (it uses GH_TOKEN for gh API calls, never git push).
persist-credentials: false
# Dogfood trusted base-branch source inside this monorepo, else install the
# published package once. Never execute PR-head recap CLI code.
- name: Resolve recap CLI
id: cli
env:
# Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to
# "latest" when unset. Set via repository variable RECAP_CLI_VERSION.
RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}
run: |
if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then
echo "local=true" >> "$GITHUB_OUTPUT"
else
echo "local=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
if: steps.cli.outputs.local == 'true'
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .recap-cli-source
fetch-depth: 1
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
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 trusted workspace recap CLI
if: steps.cli.outputs.local == 'true'
working-directory: .recap-cli-source
run: |
set -euo pipefail
pnpm install --frozen-lockfile --ignore-scripts
echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"
echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"
- 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/core@latest version)"
fi
for attempt in 1 2 3; do
if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund "@agent-native/core@$VERSION"; then
break
fi
if [ "$attempt" = "3" ]; then exit 1; fi
sleep $((attempt * 10))
done
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: 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"
- name: Collect bounded diff
id: diff
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
$RECAP_CLI recap collect-diff --base "$BASE_SHA" --head "$HEAD_SHA" --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
# Hit the plan app's action surface with the publish token. A 401 means
# the token is expired/revoked; surface it in the sticky comment so the
# repo owner knows to re-mint it instead of seeing a generic failure.
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"
- name: Secret scan
id: scan
if: steps.diff.outputs.tiny != 'true'
run: |
set -uo pipefail
# Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a
# credential-bearing diff is never handed to the agent / plan service.
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
{
echo 'json<<__RECAP_SCAN_EOF__'
echo "$SCAN_JSON"
echo '__RECAP_SCAN_EOF__'
} >> "$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
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.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 publisher 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
- name: Build recap prompt
id: prompt
if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'
env:
# Pass step outputs via env, NOT ${{ }} interpolation into the run body:
# the prev plan id is parsed from a PR comment and could inject shell.
PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}
DIFF_HUGE: ${{ steps.diff.outputs.huge }}
IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
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" --out recap-prompt.md)
if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi
if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); 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.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)
if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CLAUDE_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi
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
echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
sleep 5
run_claude
fi
- name: Run agent (Codex)
id: codex
if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != 'true'
continue-on-error: true
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -uo pipefail
# `codex login` writes ~/.codex/auth.json (the bare env var is dropped on
# the gpt-5.5 wss transport); stdin keeps the key out of process args.
printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true
# The runner is itself an ephemeral sandbox; bypass Codex's own sandbox
# (bubblewrap can't init here) and approval gate (cancels the MCP write).
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
# Validate reasoning against the enum before embedding it in the TOML override.
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
echo "::warning::recap-source.json missing after the agent run; retrying the agent once."
sleep 5
run_codex
fi
- name: Publish recap source
id: publish
if: steps.diff.outputs.tiny != 'true' && steps.scan.outputs.suppressed != '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.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
# recap-url.txt is agent-written -> untrusted. Rebuild a canonical
# recap URL from the trusted app base and a strictly validated plan id,
# preserving path-prefixed self-hosted mounts.
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.scan.outputs.suppressed != 'true'
continue-on-error: true
env:
RECAP_AGENT: ${{ needs.gate.outputs.agent }}
RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}
RECAP_PUBLISH_REASON: ${{ steps.publish.outputs.reason }}
run: |
set -uo pipefail
if [ -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"
node -e 'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_BLOCK_REFERENCE_SUMMARY || "" }) + "\n")'
exit 0
fi
if [ -n "${RECAP_PUBLISH_REASON:-}" ]; then
{
echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__'
echo "$RECAP_PUBLISH_REASON"
echo '__RECAP_PUBLISH_SUMMARY_EOF__'
} >> "$GITHUB_OUTPUT"
node -e 'process.stdout.write(JSON.stringify({ ok: true, summary: process.env.RECAP_PUBLISH_REASON || "" }) + "\n")'
exit 0
fi
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
fi
$RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || true
- name: Attach usage
if: steps.url.outputs.ok == 'true'
continue-on-error: true
env:
PLAN_URL: ${{ steps.url.outputs.plan_url }}
# Use the gate-normalized agent so "Codex" still selects the right file.
RECAP_AGENT: ${{ needs.gate.outputs.agent }}
run: |
set -uo pipefail
RESULT=claude-result.json
if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; 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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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:
# recap-url.txt is untrusted agent output; pass via env, never ${{ }}.
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
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 || 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 || 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")
if [ -z "$IMAGE_URL" ] && [ -z "$DARK_IMAGE_URL" ]; then
echo "::warning::Visual recap screenshot unavailable; posting link-only recap comment."
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"
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-${{ 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-${{ github.event.pull_request.number }}
path: |
recap-source.json
claude-result.json
claude-stderr.log
codex-events.jsonl
codex-stderr.log
if-no-files-found: ignore
retention-days: 14
- name: Upsert sticky comment
if: always() && !cancelled()
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 }}
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.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:
# Untrusted/step values via env (NOT ${{ }}-interpolated into the run
# body): the agent-written plan URL and the scan JSON could inject shell.
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.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"