Skip to content

Generate: makie for polar-bar #14432

Generate: makie for polar-bar

Generate: makie for polar-bar #14432

Workflow file for this run

name: "Impl: Generate"
run-name: "Generate: ${{ inputs.library || github.event.label.name }} for ${{ inputs.specification_id || 'issue' }}"
# Generates single library implementation
# Triggers:
# - generate:{library} label on spec-ready issue
# - workflow_dispatch with specification_id + library
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
specification_id:
description: "Specification ID (e.g., scatter-basic)"
required: true
type: string
library:
description: "Library to generate"
required: true
type: choice
options:
- matplotlib
- seaborn
- plotly
- bokeh
- altair
- plotnine
- pygal
- highcharts
- letsplot
- ggplot2
- makie
- chartjs
- d3
- echarts
- muix
issue_number:
description: "Issue number (optional, for tracking)"
required: false
type: string
model:
description: "Claude model to use (also threaded into review + repair)"
required: false
type: choice
default: 'sonnet'
options:
- haiku
- sonnet
- opus
change_request:
description: "One-sentence cross-library divergence hint from daily-regen pre-flight similarity audit (empty = none)"
required: false
type: string
default: ''
# Global concurrency: max 3 concurrent implementation workflows
concurrency:
group: impl-generate-${{ inputs.specification_id || github.event.issue.number }}-${{ inputs.library || github.event.label.name }}
cancel-in-progress: false
jobs:
generate:
# Run on label trigger OR workflow_dispatch
if: >
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'issues' && startsWith(github.event.label.name, 'generate:'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
actions: write
id-token: write
outputs:
success: ${{ steps.result.outputs.success }}
pr_number: ${{ steps.pr.outputs.pr_number }}
steps:
# ========================================================================
# Setup: Extract inputs and validate
# ========================================================================
- name: Extract inputs
id: inputs
env:
LABEL_NAME: ${{ github.event.label.name }}
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
# From workflow_dispatch
SPEC_ID="${{ inputs.specification_id }}"
LIBRARY="${{ inputs.library }}"
ISSUE="${{ inputs.issue_number }}"
MODEL="${{ inputs.model }}"
else
# From label trigger: generate:{library}
LIBRARY=$(echo "$LABEL_NAME" | sed 's/^generate://')
# Extract spec ID from issue title: [spec-id] ...
SPEC_ID=$(echo "$ISSUE_TITLE" | sed -n 's/^\[\([a-z0-9-]*\)\].*/\1/p')
ISSUE="${{ github.event.issue.number }}"
MODEL=""
fi
if [ -z "$SPEC_ID" ]; then
echo "::error::Could not determine specification ID"
exit 1
fi
if [ -z "$LIBRARY" ]; then
echo "::error::Could not determine library"
exit 1
fi
# Default model when caller didn't supply one (label trigger path).
if [ -z "$MODEL" ]; then
MODEL="sonnet"
fi
# Derive LANGUAGE + EXT from LIBRARY. Mirrors core/constants.py LIBRARIES_METADATA;
# new non-Python entries get listed here too.
case "$LIBRARY" in
ggplot2)
LANGUAGE="r"
EXT=".R"
;;
makie)
LANGUAGE="julia"
EXT=".jl"
;;
chartjs|d3|echarts|highcharts)
LANGUAGE="javascript"
EXT=".js"
;;
muix)
# MUI X — JavaScript with framework=react, authored as TSX and
# bundled through the render harness's esbuild React branch.
LANGUAGE="javascript"
EXT=".tsx"
;;
*)
LANGUAGE="python"
EXT=".py"
;;
esac
echo "specification_id=$SPEC_ID" >> $GITHUB_OUTPUT
echo "library=$LIBRARY" >> $GITHUB_OUTPUT
echo "language=$LANGUAGE" >> $GITHUB_OUTPUT
echo "ext=$EXT" >> $GITHUB_OUTPUT
echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT
echo "model=$MODEL" >> $GITHUB_OUTPUT
echo "::notice::Generating $LANGUAGE/$LIBRARY for $SPEC_ID (issue: ${ISSUE:-none}, model: ${MODEL}, ext: ${EXT})"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Read issue number from specification.yaml (fallback)
id: spec_issue
if: steps.inputs.outputs.issue_number == ''
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
run: |
SPEC_YAML="plots/${SPEC_ID}/specification.yaml"
if [ -f "$SPEC_YAML" ]; then
# Extract issue number using yq for robust YAML parsing
ISSUE=$(yq '.issue' "$SPEC_YAML" 2>/dev/null || echo "")
if [ -n "$ISSUE" ] && [ "$ISSUE" != "null" ]; then
echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT
echo "::notice::Found issue #$ISSUE from specification.yaml"
else
echo "issue_number=" >> $GITHUB_OUTPUT
echo "::warning::No issue number in specification.yaml"
fi
else
echo "issue_number=" >> $GITHUB_OUTPUT
echo "::warning::specification.yaml not found"
fi
# Consolidate issue number from inputs or fallback
- name: Set final issue number
id: issue
run: |
ISSUE="${{ steps.inputs.outputs.issue_number || steps.spec_issue.outputs.issue_number }}"
echo "number=$ISSUE" >> $GITHUB_OUTPUT
if [ -n "$ISSUE" ]; then
echo "::notice::Using issue #$ISSUE for tracking"
else
echo "::warning::No issue number available - PR will not have Parent Issue link"
fi
- name: Validate specification exists
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
run: |
if [ ! -f "plots/${SPEC_ID}/specification.md" ]; then
echo "::error::Specification not found: plots/${SPEC_ID}/specification.md"
exit 1
fi
- name: Reopen issue if closed
if: steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ steps.issue.outputs.number }}
run: |
# Reopen issue so it's visible during implementation
gh issue reopen "$ISSUE" 2>/dev/null || true
- name: Add pending label
if: steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LIBRARY: ${{ steps.inputs.outputs.library }}
ISSUE: ${{ steps.issue.outputs.number }}
run: |
gh issue edit "$ISSUE" --add-label "impl:${LIBRARY}:pending" 2>/dev/null || true
# ========================================================================
# Setup: Python and dependencies (always installed — needed for image
# processing + metadata writing even for non-Python implementations).
# ========================================================================
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pngquant
- name: Install Python plotting dependencies
if: steps.inputs.outputs.language == 'python'
env:
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
uv venv .venv
source .venv/bin/activate
# Source of truth: pyproject.toml lib-${LIBRARY} extras
uv pip install -e ".[lib-${LIBRARY}]" ruff pillow pyyaml
- name: Install Python helper deps (for non-Python implementations)
if: steps.inputs.outputs.language != 'python'
run: |
# ggplot2 / future non-Python libs render via their own runtime,
# but the pipeline still uses Python for metadata + image post-processing.
uv venv .venv
source .venv/bin/activate
uv pip install pillow pyyaml
- name: Setup R + ggplot2
if: steps.inputs.outputs.language == 'r'
uses: ./.github/actions/setup-r
- name: Setup Julia + Makie
if: steps.inputs.outputs.language == 'julia'
uses: ./.github/actions/setup-julia
- name: Setup Node + browser render harness
if: steps.inputs.outputs.language == 'javascript'
uses: ./.github/actions/setup-node
# ========================================================================
# Generate: Create implementation branch and code
# ========================================================================
- name: Create implementation branch
id: branch
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
BRANCH="implementation/${SPEC_ID}/${LIBRARY}"
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
# Delete branch if exists (regeneration)
git push origin --delete "$BRANCH" 2>/dev/null || true
# Create fresh branch from main
git checkout -b "$BRANCH" origin/main
echo "::notice::Created branch: $BRANCH"
- name: Check for existing implementation (regeneration)
id: existing
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
run: |
METADATA_FILE="plots/${SPEC_ID}/metadata/${LANGUAGE}/${LIBRARY}.yaml"
IMPL_FILE="plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}${EXT}"
if [ -f "$METADATA_FILE" ] && [ -f "$IMPL_FILE" ]; then
echo "is_regeneration=true" >> $GITHUB_OUTPUT
echo "::notice::Regeneration detected - will read previous review feedback"
else
echo "is_regeneration=false" >> $GITHUB_OUTPUT
fi
- name: Extract previous review feedback (regeneration)
id: prev_review
if: steps.existing.outputs.is_regeneration == 'true'
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
.venv/bin/python3 - <<'PY'
import os, pathlib, yaml
spec = os.environ["SPEC_ID"]
lang = os.environ["LANGUAGE"]
lib = os.environ["LIBRARY"]
meta_path = pathlib.Path(f"plots/{spec}/metadata/{lang}/{lib}.yaml")
data = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}
review = data.get("review") or {}
quality = data.get("quality_score")
lines = [f"# Previous Review for {spec} / {lang} / {lib}", ""]
lines.append(f"**Previous quality score:** {quality if quality is not None else 'n/a'}")
lines.append("")
desc = review.get("image_description")
if desc:
lines += ["## Previous image description", str(desc).strip(), ""]
strengths = review.get("strengths") or []
if strengths:
lines.append("## Strengths (KEEP these)")
lines += [f"- {s}" for s in strengths]
lines.append("")
weaknesses = review.get("weaknesses") or []
if weaknesses:
lines.append("## Weaknesses (FIX these)")
lines += [f"- {w}" for w in weaknesses]
lines.append("")
checklist = review.get("criteria_checklist") or {}
if checklist:
lines.append("## Criteria checklist (focus on items that failed)")
for cat, payload in checklist.items():
payload = payload or {}
score = payload.get("score", "?")
max_score = payload.get("max", "?")
lines.append(f"### {cat} ({score}/{max_score})")
for item in payload.get("items") or []:
item = item or {}
mark = "✅" if item.get("passed") else "❌"
lines.append(
f"- {mark} {item.get('id', '?')} {item.get('name', '')}: {item.get('comment', '')}"
)
lines.append("")
pathlib.Path("/tmp/anyplot-prev-review.md").write_text("\n".join(lines), encoding="utf-8")
print(f"::notice::Extracted previous review (score={quality}) to /tmp/anyplot-prev-review.md")
PY
- name: Ensure implementation directories exist
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
run: |
mkdir -p "plots/${SPEC_ID}/implementations/${LANGUAGE}"
mkdir -p "plots/${SPEC_ID}/metadata/${LANGUAGE}"
echo "::notice::Ensured implementation + metadata directories exist for language '${LANGUAGE}'"
- name: Stage change_request hint (cross-library divergence)
if: ${{ inputs.change_request != '' }}
env:
CHANGE_REQUEST: ${{ inputs.change_request }}
run: |
# Written to a file so the prompt template stays variable-free; impl-generate-claude.md
# checks for the file's existence and reads it if present.
printf '%s\n' "$CHANGE_REQUEST" > /tmp/anyplot-change-request.txt
echo "::notice::Change request staged: ${CHANGE_REQUEST}"
# When triggered from a non-main branch via workflow_dispatch (typical for
# iterative prompt-change testing), overlay the trigger branch's prompts/
# directory onto the implementation branch (which was just created from
# main). Claude commits only the implementation file (see Step 7 of
# impl-generate-claude.md → `git add plots/.../{LIBRARY}{EXT}`), so the
# overlaid prompts/ never end up in the resulting PR. The later "Create
# library metadata file" step re-checks out origin/$BRANCH, which discards
# the overlay automatically. No-op on main + on label triggers.
#
# FETCH_HEAD is used instead of origin/<name> so the step also works for
# tag and SHA refs (where origin/<name> isn't created). The existence
# check for prompts/ guards refs without that tree. impl-generate-claude.md
# is always restored from the implementation branch (= main) after the
# overlay, so a branch can't override Claude's `git add plots/...` safety
# contract by editing that one file.
- name: Overlay prompts/ from trigger ref (branch-level prompt iteration)
if: github.ref_name != 'main' && github.event_name == 'workflow_dispatch'
env:
TRIGGER_REF: ${{ github.ref_name }}
run: |
if ! git fetch origin "$TRIGGER_REF" 2>/dev/null; then
echo "::notice::Trigger ref $TRIGGER_REF not found on origin — skipping overlay (workflow runs against main's prompts)"
exit 0
fi
if ! git ls-tree FETCH_HEAD -- prompts/ 2>/dev/null | grep -q .; then
echo "::notice::Trigger ref $TRIGGER_REF has no prompts/ tree — skipping overlay"
exit 0
fi
git checkout FETCH_HEAD -- prompts/
# Always restore impl-generate-claude.md from main: it tells Claude
# `git add plots/.../{LIBRARY}{EXT}` (Step 7), which is what keeps
# the overlaid prompts/ out of the resulting PR. Letting a feature
# branch override that instruction defeats the safety contract above.
git checkout HEAD -- prompts/workflow-prompts/impl-generate-claude.md
echo "::notice::Overlaid prompts/ from $TRIGGER_REF (impl-generate-claude.md kept from main for safety)"
- name: Run Claude Code to generate implementation
id: claude
continue-on-error: true
timeout-minutes: 60
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: "--model ${{ steps.inputs.outputs.model }} --settings ${{ github.workspace }}/.claude/settings.json"
# bulk-generate dispatches us from the github-actions bot; explicitly allow it.
allowed_bots: '*'
prompt: |
Read `prompts/workflow-prompts/impl-generate-claude.md` and follow those instructions.
Variables for this run:
- LANGUAGE: ${{ steps.inputs.outputs.language }}
- LIBRARY: ${{ steps.inputs.outputs.library }}
- EXT: ${{ steps.inputs.outputs.ext }}
- SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
- IS_REGENERATION: ${{ steps.existing.outputs.is_regeneration }}
- name: Retry Claude (on failure)
if: steps.claude.outcome == 'failure'
id: claude_retry
timeout-minutes: 60
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: "--model ${{ steps.inputs.outputs.model }} --settings ${{ github.workspace }}/.claude/settings.json"
# bulk-generate dispatches us from the github-actions bot; explicitly allow it.
allowed_bots: '*'
prompt: |
Read `prompts/workflow-prompts/impl-generate-claude.md` and follow those instructions.
Variables for this run:
- LANGUAGE: ${{ steps.inputs.outputs.language }}
- LIBRARY: ${{ steps.inputs.outputs.library }}
- EXT: ${{ steps.inputs.outputs.ext }}
- SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
- IS_REGENERATION: ${{ steps.existing.outputs.is_regeneration }}
# ========================================================================
# Create metadata file (before PR)
# ========================================================================
- name: Create library metadata file
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
ISSUE: ${{ steps.issue.outputs.number }}
BRANCH: ${{ steps.branch.outputs.branch }}
MODEL: ${{ steps.inputs.outputs.model }}
run: |
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
# Save plot files before git operations (they are not committed and would be lost).
# Phase C emits plot-light.png + plot-dark.png (+ plot-light.html + plot-dark.html
# for interactive libs); legacy plot.png/plot.html is preserved during the transition.
mkdir -p /tmp/anyplot-plot-cache
rm -rf /tmp/anyplot-plot-cache/*
for f in plot-light.png plot-dark.png plot-light.html plot-dark.html plot.png plot.html; do
cp "${IMPL_DIR}/${f}" "/tmp/anyplot-plot-cache/${f}" 2>/dev/null || true
done
# Configure git auth (Claude's action configured it, but it's gone now)
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
# Sync with remote (Claude already pushed the code)
git fetch origin
# Check if remote branch exists before checkout (fixes branch-not-found error)
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git checkout -B "$BRANCH" "origin/$BRANCH"
else
# Branch doesn't exist on remote - create fresh from main
echo "::warning::Remote branch $BRANCH not found, creating fresh from main"
git checkout -B "$BRANCH" origin/main
fi
# Restore plot files after git operations
mkdir -p "${IMPL_DIR}"
for f in plot-light.png plot-dark.png plot-light.html plot-dark.html plot.png plot.html; do
cp "/tmp/anyplot-plot-cache/${f}" "${IMPL_DIR}/${f}" 2>/dev/null || true
done
# Now create metadata file
METADATA_DIR="plots/${SPEC_ID}/metadata/${LANGUAGE}"
mkdir -p "${METADATA_DIR}"
METADATA_FILE="${METADATA_DIR}/${LIBRARY}.yaml"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
mkdir -p "$METADATA_DIR"
# python_version = the Python interpreter that ran THIS pipeline (always 3.13).
# language_version = the implementation's own runtime — same as python_version for
# Python libs, the R version for ggplot2. The frontend renders language_version with
# the right language label, so we never mislabel R as "Python".
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
# R/Python versions are always dot-separated digits. Anything else
# captured from Rscript (renv leaks, error fragments, status messages)
# would poison metadata and break the downstream Python heredoc, so
# we reject and let the existing "unknown" fallback take over.
is_version() {
[[ "$1" =~ ^[0-9]+(\.[0-9]+)*$ ]]
}
if [ "$LANGUAGE" = "r" ]; then
# renv's "out-of-sync" notice prints on stdout from .Rprofile, so 2>/dev/null
# leaves it in the captured value. RENV_CONFIG_STARTUP_QUIET silences renv;
# tail -n1 + is_version reject anything else that might leak in or land if
# Rscript exits non-zero after partial stdout.
LANGUAGE_VERSION=$(RENV_CONFIG_STARTUP_QUIET=TRUE Rscript -e 'cat(as.character(getRversion()))' 2>/dev/null | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
elif [ "$LANGUAGE" = "julia" ]; then
# `julia -e 'print(VERSION)'` prints `1.11.2` (or similar) on stdout.
# tail -n1 + is_version reject anything else that might leak in or land
# if julia exits non-zero after partial stdout.
LANGUAGE_VERSION=$(julia -e 'print(VERSION)' 2>/dev/null | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
elif [ "$LANGUAGE" = "javascript" ]; then
# `node --version` prints `v22.x.y`; strip the leading `v`.
LANGUAGE_VERSION=$(node --version 2>/dev/null | sed 's/^v//' | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
else
LANGUAGE_VERSION="$PYTHON_VERSION"
fi
# Get library version. Python libs read via `pip show`; R libs via
# packageVersion() inside Rscript; Julia libs via Pkg API.
# Names that differ between catalogue id and registry id are mapped
# explicitly.
get_pip_version() {
.venv/bin/pip show "$1" 2>/dev/null | grep -i "^Version:" | awk '{print $2}'
}
get_r_version() {
local v
# `|| true` on the read + if/fi (not `&&`) on the check: neither may
# propagate a non-zero exit, or it aborts under `set -e`/`-o pipefail`.
v=$(RENV_CONFIG_STARTUP_QUIET=TRUE Rscript -e "cat(as.character(packageVersion('$1')))" 2>/dev/null | tail -n1) || true
if is_version "$v"; then printf '%s' "$v"; fi
}
get_julia_version() {
# Pkg.dependencies() returns a Dict{UUID, PackageInfo}; locate the
# entry by name and print its `.version`. Any version-parse failure
# gets rejected by the is_version check downstream.
local v
v=$(julia --project=. -e "
using Pkg
for (_, info) in Pkg.dependencies()
if info.name == \"$1\"
print(info.version)
break
end
end
" 2>/dev/null | tail -n1) || true
# `|| true` on the read + if/fi (not `&&`) on the check: neither may
# propagate a non-zero exit, or it aborts under `set -e`/`-o pipefail`.
if is_version "$v"; then printf '%s' "$v"; fi
}
get_npm_version() {
# Read the installed version straight out of the package's own
# package.json (restored by `npm ci` from the committed lockfile).
# NB: read the file via fs, NOT `require('<pkg>/package.json')`:
# packages with an `exports` map (d3, chart.js) block that subpath
# with ERR_PACKAGE_PATH_NOT_EXPORTED, while echarts happens to allow
# it — reading node_modules/<pkg>/package.json directly is uniform.
local v
# `|| true` on the read + if/fi (not `&&`) on the check guard BOTH
# abort points: otherwise `LIBRARY_VERSION=$(get_npm_version …)`
# inherits a non-zero exit and dies under `set -e`/`-o pipefail`
# before the `"unknown"` fallback can run.
v=$(node -p "JSON.parse(require('fs').readFileSync('node_modules/$1/package.json','utf8')).version" 2>/dev/null | tail -n1) || true
if is_version "$v"; then printf '%s' "$v"; fi
}
if [ "$LANGUAGE" = "r" ]; then
LIBRARY_VERSION=$(get_r_version "$LIBRARY")
elif [ "$LANGUAGE" = "julia" ]; then
# Catalogue id `makie` maps to the Julia package `Makie` (CairoMakie
# bundles the same version; we report the user-facing Makie version).
case "$LIBRARY" in
makie)
LIBRARY_VERSION=$(get_julia_version "Makie")
;;
*)
LIBRARY_VERSION=$(get_julia_version "$LIBRARY")
;;
esac
elif [ "$LANGUAGE" = "javascript" ]; then
# Catalogue id `chartjs` maps to the npm package `chart.js` and
# `muix` to the scoped community package `@mui/x-charts`; `d3` and
# `echarts` match their npm names directly.
case "$LIBRARY" in
chartjs)
LIBRARY_VERSION=$(get_npm_version "chart.js")
;;
muix)
LIBRARY_VERSION=$(get_npm_version "@mui/x-charts")
;;
*)
LIBRARY_VERSION=$(get_npm_version "$LIBRARY")
;;
esac
else
case "$LIBRARY" in
letsplot)
LIBRARY_VERSION=$(get_pip_version "lets-plot")
;;
*)
LIBRARY_VERSION=$(get_pip_version "$LIBRARY")
;;
esac
# Fallback if version is empty
if [ -z "$LIBRARY_VERSION" ]; then
echo "::warning::Could not get version for $LIBRARY, trying alternative method"
# Map library names to Python module names
case "$LIBRARY" in
letsplot) PYTHON_MODULE="lets_plot" ;;
plotnine) PYTHON_MODULE="plotnine" ;;
*) PYTHON_MODULE="$LIBRARY" ;;
esac
LIBRARY_VERSION=$(.venv/bin/python -c "import $PYTHON_MODULE; print(getattr($PYTHON_MODULE, '__version__', 'unknown'))" 2>/dev/null || echo "unknown")
fi
fi
if [ -z "$LIBRARY_VERSION" ]; then
LIBRARY_VERSION="unknown"
fi
echo "::notice::Library version: $LIBRARY = $LIBRARY_VERSION ($LANGUAGE runtime $LANGUAGE_VERSION, pipeline python $PYTHON_VERSION)"
# Interactive libraries additionally produce HTML previews (one per theme).
# Python interactive libs are listed explicitly. JavaScript libs are
# interactive too (the browser render harness emits standalone
# plot-{light,dark}.html) — but gate on the files actually existing, so a
# lib/render that produced no HTML never points preview_html at a 404.
HAS_HTML="false"
case "$LIBRARY" in
plotly|bokeh|altair|pygal|letsplot) HAS_HTML="true" ;;
esac
if [ "$LANGUAGE" = "javascript" ] && [ -f "$IMPL_DIR/plot-light.html" ] && [ -f "$IMPL_DIR/plot-dark.html" ]; then
HAS_HTML="true"
fi
# Write metadata file using Python for proper YAML formatting
# Pass all variables inline to avoid export/env issues
.venv/bin/python3 -c "
import os, yaml
lib = '$LIBRARY'
spec = '$SPEC_ID'
language = '$LANGUAGE'
ts = '$TIMESTAMP'
run_id = ${{ github.run_id }}
issue = int('$ISSUE' or '0')
lang_ver = '$LANGUAGE_VERSION'
lib_ver = '$LIBRARY_VERSION'
has_html = '$HAS_HTML' == 'true'
metadata_file = '$METADATA_FILE'
model = '$MODEL'
base_url = f'https://storage.googleapis.com/anyplot-images/plots/{spec}/{language}/{lib}'
# Preserve the original 'created' timestamp on regenerations.
# 'created' is the first-generation date — it must never be overwritten.
# Only 'updated' moves forward on every regen.
created_ts = ts
if os.path.exists(metadata_file):
try:
with open(metadata_file) as f:
existing = yaml.safe_load(f) or {}
if existing.get('created'):
created_ts = existing['created']
except Exception:
pass
data = {
'library': lib,
'language': language,
'specification_id': spec,
'created': created_ts,
'updated': ts,
# Reflects what claude_args='--model {model}' actually runs: whatever
# Claude Code's current alias for the chosen model resolves to.
# Use the family name instead of a frozen version string so the
# metadata doesn't go stale every model release.
'generated_by': f'claude-{model}',
'workflow_run': run_id,
'issue': issue,
# language_version is the runtime of the implementation's own language
# (Python for matplotlib/seaborn/..., R for ggplot2). The pipeline's own
# Python interpreter is no longer written here — workflow_run is enough
# for audit, and python_version on an R artifact was misleading.
'language_version': lang_ver,
'library_version': lib_ver,
# Theme-aware preview URLs (Phase C). Both PNG variants are always emitted.
'preview_url_light': f'{base_url}/plot-light.png',
'preview_url_dark': f'{base_url}/plot-dark.png',
'preview_html_light': f'{base_url}/plot-light.html' if has_html else None,
'preview_html_dark': f'{base_url}/plot-dark.html' if has_html else None,
'quality_score': None,
'review': {'strengths': [], 'weaknesses': []}
}
with open(metadata_file, 'w') as f:
f.write(f'# Per-library metadata for {lib} implementation of {spec}\n')
f.write('# Auto-generated by impl-generate.yml\n\n')
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
"
# Commit and push metadata (implementation already committed by Claude)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
IMPL_FILE="plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}${EXT}"
# Verify implementation file exists in the repository (Claude should have committed it)
if ! git ls-files --error-unmatch "$IMPL_FILE" >/dev/null 2>&1; then
echo "::error::Implementation file not found in repository - cannot commit"
echo "::error::Expected implementation at: $IMPL_FILE"
echo "::error::This indicates Claude failed to create the implementation file"
exit 1
fi
# Add metadata file
git add "$METADATA_FILE"
# Verify metadata file is staged
if ! git diff --cached --name-only | grep -q "$(basename "$METADATA_FILE")"; then
echo "::error::Metadata file not staged - cannot commit"
echo "::error::This indicates the metadata file was not added correctly"
exit 1
fi
git commit -m "chore(${LIBRARY}): add metadata for ${SPEC_ID}"
# Retry git push — transient races against Claude's earlier push were
# the dominant "Create metadata file" failure mode (6x in 24h on
# 2026-05-06). On a non-fast-forward, fetch + rebase before retrying;
# if the rebase itself fails (conflicts), abort fast — leaving a
# half-rebased repo in place would just hide the real error.
push_ok=0
for attempt in 1 2 3; do
if git push origin "$BRANCH"; then
push_ok=1
break
fi
echo "::warning::git push failed (attempt ${attempt}/3) — fetching + rebasing then retrying"
if ! git fetch origin "$BRANCH"; then
echo "::error::git fetch origin ${BRANCH} failed during retry"
exit 1
fi
if ! git pull --rebase origin "$BRANCH"; then
echo "::error::git rebase against origin/${BRANCH} failed (conflicts) — aborting"
git rebase --abort 2>/dev/null || true
exit 1
fi
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
if [ "$push_ok" != "1" ]; then
echo "::error::git push failed after 3 attempts on branch ${BRANCH}"
exit 1
fi
echo "::notice::Created metadata file: $METADATA_FILE (py${PYTHON_VERSION}, ${LIBRARY}==${LIBRARY_VERSION})"
echo "::notice::Implementation verified present, metadata committed"
# ========================================================================
# Process images: optimize + thumbnail
# ========================================================================
- name: Process plot images (light + dark)
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
run: |
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
if [ ! -f "$IMPL_DIR/plot-light.png" ] || [ ! -f "$IMPL_DIR/plot-dark.png" ]; then
echo "::error::Missing plot-{light,dark}.png — implementation failed to produce both theme renders"
echo "::error::Expected both ${IMPL_DIR}/plot-light.png and ${IMPL_DIR}/plot-dark.png"
ls -la "$IMPL_DIR/" || true
exit 1
fi
source .venv/bin/activate
# Optimize each theme PNG in place, then generate responsive variants
# (400/800/1200 x png/webp + full webp) named plot-{theme}_*.{png,webp}.
for theme in light dark; do
python -m core.images process \
"$IMPL_DIR/plot-${theme}.png" \
"$IMPL_DIR/plot-${theme}.png"
python -m core.images responsive \
"$IMPL_DIR/plot-${theme}.png" \
"$IMPL_DIR/"
done
echo "::notice::Processed both themes: plot-light + plot-dark (optimized + responsive variants)"
ls -la "$IMPL_DIR/"
# ========================================================================
# Create PR
# ========================================================================
- name: Create Pull Request
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
ISSUE: ${{ steps.issue.outputs.number }}
BRANCH: ${{ steps.branch.outputs.branch }}
run: |
# Check if PR already exists
EXISTING_PR=$(gh pr list --head "$BRANCH" --base main --json number -q '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
echo "pr_number=$EXISTING_PR" >> $GITHUB_OUTPUT
echo "pr_exists=true" >> $GITHUB_OUTPUT
echo "::notice::Using existing PR #$EXISTING_PR"
exit 0
fi
# Create PR body
BODY="## Implementation: \`${SPEC_ID}\` - ${LANGUAGE}/${LIBRARY}
Implements the **${LANGUAGE}/${LIBRARY}** version of \`${SPEC_ID}\`.
**File:** \`plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}${EXT}\`"
if [ -n "$ISSUE" ]; then
BODY="${BODY}
**Parent Issue:** #${ISSUE}"
fi
BODY="${BODY}
---
:robot: *[impl-generate workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
# Create PR
PR_URL=$(gh pr create --base main --head "$BRANCH" \
--title "feat(${LIBRARY}): implement ${SPEC_ID}" \
--body "$BODY")
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "pr_exists=true" >> $GITHUB_OUTPUT
echo "::notice::Created PR #$PR_NUMBER"
# ========================================================================
# Upload to GCS Staging
# ========================================================================
- name: Authenticate to GCP
id: gcp_auth
if: steps.pr.outputs.pr_exists == 'true'
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
with:
project_id: anyplot
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
- name: Set up Cloud SDK
id: gcloud
if: steps.pr.outputs.pr_exists == 'true'
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3
- name: Upload to GCS Staging
id: gcs
if: steps.pr.outputs.pr_exists == 'true'
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
STAGING_PATH="gs://anyplot-images/staging/${SPEC_ID}/${LANGUAGE}/${LIBRARY}"
PUBLIC_URL="https://storage.googleapis.com/anyplot-images/staging/${SPEC_ID}/${LANGUAGE}/${LIBRARY}"
# Require both theme renders
if [ ! -f "$IMPL_DIR/plot-light.png" ] || [ ! -f "$IMPL_DIR/plot-dark.png" ]; then
echo "::error::Missing plot-light.png and/or plot-dark.png — cannot upload"
ls -la "$IMPL_DIR/" || true
exit 1
fi
# Upload all plot images for both themes (originals + responsive variants)
gsutil -m -h "Cache-Control:public, max-age=604800" cp \
"$IMPL_DIR"/plot-light*.png "$IMPL_DIR"/plot-light*.webp \
"$IMPL_DIR"/plot-dark*.png "$IMPL_DIR"/plot-dark*.webp \
"${STAGING_PATH}/"
gsutil -m acl ch -u AllUsers:R "${STAGING_PATH}/plot-light*" "${STAGING_PATH}/plot-dark*" 2>/dev/null || true
echo "png_url_light=${PUBLIC_URL}/plot-light.png" >> $GITHUB_OUTPUT
echo "png_url_dark=${PUBLIC_URL}/plot-dark.png" >> $GITHUB_OUTPUT
echo "uploaded=true" >> $GITHUB_OUTPUT
echo "::notice::Uploaded plot-light + plot-dark (+responsive variants)"
# Upload HTML for interactive libraries (one per theme)
for theme in light dark; do
if [ -f "$IMPL_DIR/plot-${theme}.html" ]; then
gsutil -h "Cache-Control:public, max-age=604800" cp "$IMPL_DIR/plot-${theme}.html" "${STAGING_PATH}/plot-${theme}.html"
gsutil acl ch -u AllUsers:R "${STAGING_PATH}/plot-${theme}.html" 2>/dev/null || true
echo "html_url_${theme}=${PUBLIC_URL}/plot-${theme}.html" >> $GITHUB_OUTPUT
fi
done
rm -f /tmp/gcs-key.json
# ========================================================================
# Post preview and trigger review
# ========================================================================
- name: Post preview to issue
if: steps.issue.outputs.number != '' && steps.gcs.outputs.uploaded == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
ISSUE: ${{ steps.issue.outputs.number }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
# Use staging URLs (available immediately after upload) — show both themes side by side.
BASE="https://storage.googleapis.com/anyplot-images/staging/${SPEC_ID}/${LANGUAGE}/${LIBRARY}"
PNG_LIGHT="${BASE}/plot-light.png"
PNG_DARK="${BASE}/plot-dark.png"
HTML_LIGHT=""
HTML_DARK=""
case "$LIBRARY" in
plotly|bokeh|altair|pygal|letsplot)
HTML_LIGHT="${BASE}/plot-light.html"
HTML_DARK="${BASE}/plot-dark.html"
;;
esac
# JS libs are interactive too — link the HTML only when the render
# produced it (mirrors the existence gate used for preview_html).
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
if [ "$LANGUAGE" = "javascript" ] && [ -f "$IMPL_DIR/plot-light.html" ] && [ -f "$IMPL_DIR/plot-dark.html" ]; then
HTML_LIGHT="${BASE}/plot-light.html"
HTML_DARK="${BASE}/plot-dark.html"
fi
BODY="## :art: ${LANGUAGE}/${LIBRARY} Preview
| Light | Dark |
|-------|------|
| ![${LIBRARY} light](${PNG_LIGHT}) | ![${LIBRARY} dark](${PNG_DARK}) |
**PR:** #${PR_NUMBER}"
if [ -n "$HTML_LIGHT" ]; then
BODY="${BODY}
**Interactive:** [light](${HTML_LIGHT}) · [dark](${HTML_DARK})"
fi
BODY="${BODY}
---
:robot: *[impl-generate](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
gh issue comment "$ISSUE" --body "$BODY"
- name: Trigger review workflow
id: review_dispatch
if: steps.pr.outputs.pr_exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
MODEL: ${{ steps.inputs.outputs.model }}
run: |
# Use repository_dispatch as workaround for workflow_dispatch caching issue
gh api repos/${{ github.repository }}/dispatches \
-f event_type=review-pr \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[model]=$MODEL"
echo "::notice::Triggered impl-review.yml via repository_dispatch for PR #$PR_NUMBER (model=$MODEL)"
- name: Determine result
id: result
run: |
if [ "${{ steps.pr.outputs.pr_exists }}" == "true" ]; then
echo "success=true" >> $GITHUB_OUTPUT
else
echo "success=false" >> $GITHUB_OUTPUT
fi
# ========================================================================
# Failure handling: Track failures via comments and auto-retry
# ========================================================================
- name: Handle generation failure
if: failure() && steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LIBRARY: ${{ steps.inputs.outputs.library }}
ISSUE: ${{ steps.issue.outputs.number }}
MODEL: ${{ steps.inputs.outputs.model }}
# Pass via env (not template-interpolated into the script) so hints
# containing quotes / $ / backticks can't break shell parsing.
CHANGE_REQUEST: ${{ inputs.change_request }}
# How far back a failure marker still counts toward the 3-attempt cap.
# Covers a whole campaign (a generate→review→repair→merge cycle runs
# well under an hour) with room for a stalled tail, without letting
# last month's failures veto today's retries.
CAMPAIGN_WINDOW_H: '12'
# Step outcomes, to tell an infrastructure failure (provider incident,
# cloud auth, GitHub API) from the agent's own — only the latter counts
# toward the 3-attempt cap. Only the retry step is inspected: it runs
# solely when the first Claude step failed, so its failure means both
# runs died (the first step has continue-on-error and never fails the
# job by itself).
CLAUDE_RETRY_OUTCOME: ${{ steps.claude_retry.outcome }}
CLAUDE_EXEC_FILE: ${{ steps.claude_retry.outputs.execution_file }}
GCP_AUTH_OUTCOME: ${{ steps.gcp_auth.outcome }}
GCLOUD_OUTCOME: ${{ steps.gcloud.outcome }}
GCS_OUTCOME: ${{ steps.gcs.outcome }}
PR_OUTCOME: ${{ steps.pr.outcome }}
REVIEW_DISPATCH_OUTCOME: ${{ steps.review_dispatch.outcome }}
# Infrastructure failures are retried without spending the pair's
# budget, but not forever: after this many in the window the pair is
# parked WITHOUT `impl:<lib>:failed` (it is not a capability verdict)
# and left for the next dispatch.
INFRA_WINDOW_CAP: '5'
run: |
echo "::notice::Handling generation failure for $LIBRARY/$SPEC_ID"
# Classify the failure. The retry cap exists to stop re-running a pair
# the model cannot solve; a provider incident, a cloud token refresh
# or a GitHub 5xx says nothing about the pair. During the Claude
# outage of 2026-09-02 (03:15–03:45 UTC) every run failed with
# `is_error:true` + "Internal error", 27 pairs burned all three
# attempts in twenty minutes, and for the next twelve hours each
# re-dispatch ran without any auto-retry because the markers counted.
#
# Infrastructure = both Claude runs ended in the action's own error
# path with a provider-side signature (the execution log is checked,
# so an agent that gave up — max turns, refused — still counts), or
# the GCP auth / Cloud SDK / GCS upload / PR creation / review
# dispatch step failed. "Implementation file not found" and a missing
# theme render are the agent's own and keep counting.
INFRA_CAUSE=""
if [ "${CLAUDE_RETRY_OUTCOME}" = "failure" ]; then
if [ -n "${CLAUDE_EXEC_FILE}" ] && [ -f "${CLAUDE_EXEC_FILE}" ] \
&& grep -qiE 'Internal error|overloaded|rate.?limit|too many requests|ECONNRESET|ETIMEDOUT|"status": *5[0-9][0-9]' "${CLAUDE_EXEC_FILE}"; then
SNIPPET=$(grep -oiE 'Internal error[^"]{0,60}|overloaded[^"]{0,40}|rate.?limit[^"]{0,40}|too many requests|ECONNRESET|ETIMEDOUT|"status": *5[0-9][0-9]' "${CLAUDE_EXEC_FILE}" | head -1)
INFRA_CAUSE="Claude Code action error: ${SNIPPET:-provider-side error, see the execution log}"
fi
elif [ "${GCP_AUTH_OUTCOME}" = "failure" ] || [ "${GCLOUD_OUTCOME}" = "failure" ] || [ "${GCS_OUTCOME}" = "failure" ]; then
INFRA_CAUSE="Google Cloud auth/upload step failed"
elif [ "${PR_OUTCOME}" = "failure" ] || [ "${REVIEW_DISPATCH_OUTCOME}" = "failure" ]; then
INFRA_CAUSE="GitHub API step failed (PR creation or review dispatch)"
fi
if [ -n "${INFRA_CAUSE}" ]; then
echo "::notice::Classified as infrastructure failure: ${INFRA_CAUSE}"
fi
# Count previous failures via hidden marker comments (more reliable than workflow runs).
# Paginate so the marker is found even on issues with >30 comments
# (which is common because all 15 library impls land on the same issue).
#
# FAIL CLOSED: if the count call errors (rate limit, transient API
# failure), we must NOT assume zero prior failures — a zero fallback
# turns the 3-attempt cap into an infinite self-retry loop (each
# retry adds a failure comment, which makes the count call slower and
# more rate-limit-prone, which re-triggers the zero fallback; issue
# #1010 flooded ~1,200 Actions runs in 38h exactly this way).
# A failed count is therefore treated as cap-reached: no auto-retry.
# NB: --paginate applies --jq to EACH page separately, so the filter
# emits one per-page count per line; awk sums them into one number
# (the old code broke on issues with >100 comments for this reason).
# pipefail (Actions bash default) makes a gh failure fail the whole
# pipeline, so the fail-closed else-branch still triggers.
#
# CAMPAIGN-SCOPED: only markers younger than CAMPAIGN_WINDOW_H count.
# The markers are the permanent audit trail — nothing deletes them —
# so counting all of them made the "3 attempts" cap per-issue-lifetime
# instead of per-generation-run. Any (spec, library) pair that ever
# failed twice then got exactly ONE attempt on every future dispatch,
# and a single hit of the ~9%/run "agent reports success but writes no
# file" flake parked it under `impl:<lib>:failed`, which nothing
# retries (the watchdog reports it as "needs manual attention").
# Measured 2026-08-24: counts of 3 and 4 on pairs that then succeeded
# on the very next manual dispatch, and 87 pairs parked repo-wide.
# A generation campaign is minutes long, so a window of hours is
# generous while still letting stale history age out on its own.
#
# INFRASTRUCTURE-AWARE: a marker that also carries INFRA_TAG records a
# failure that was not the agent's (see the classification above).
# Those are counted separately: they never spend the 3-attempt budget
# and are bounded by INFRA_WINDOW_CAP instead.
MARKER="<!-- impl-fail:${SPEC_ID}:${LIBRARY} -->"
INFRA_TAG="<!-- impl-fail-cause:infra -->"
CAMPAIGN_CUTOFF=$(date -u -d "${CAMPAIGN_WINDOW_H} hours ago" +%Y-%m-%dT%H:%M:%SZ)
# One paginated call, two numbers per page ("<genuine> <infra>"), summed by awk.
if COUNTS=$(gh api --paginate "repos/${{ github.repository }}/issues/${ISSUE}/comments?per_page=100" \
--jq "[.[] | select(.body != null and (.body | contains(\"$MARKER\")) and .created_at > \"$CAMPAIGN_CUTOFF\")] | \"\(map(select(.body | contains(\"$INFRA_TAG\") | not)) | length) \(map(select(.body | contains(\"$INFRA_TAG\"))) | length)\"" \
| awk '{ g += $1; i += $2 } END { print (g + 0) " " (i + 0) }'); then
FAILURE_COUNT=${COUNTS% *}
INFRA_COUNT=${COUNTS#* }
echo "::notice::Previous failures for ${LIBRARY}/${SPEC_ID} since ${CAMPAIGN_CUTOFF}: $FAILURE_COUNT (plus $INFRA_COUNT infrastructure failures, not counted)"
else
echo "::warning::Failure-count API call failed — failing closed (treating retry cap as reached, no auto-retry)"
FAILURE_COUNT=999
INFRA_COUNT=999
fi
# Retry dispatch, shared by both branches below. Forwards
# `change_request` so cross-library divergence hints from daily-regen
# pre-flight survive the retry — otherwise the first attempt has the
# hint but the retry doesn't, defeating the audit. CHANGE_REQUEST is
# read from env to keep raw quotes/$/backticks inside the hint from
# breaking shell parsing.
dispatch_retry() {
gh workflow run impl-generate.yml \
-f specification_id="${SPEC_ID}" \
-f library="${LIBRARY}" \
-f issue_number="${ISSUE}" \
-f model="${MODEL}" \
-f change_request="${CHANGE_REQUEST}"
if [ -n "${CHANGE_REQUEST}" ]; then
echo "::notice::Triggered automatic retry for ${LIBRARY}/${SPEC_ID} ($1, model=${MODEL}, change_request=present)"
else
echo "::notice::Triggered automatic retry for ${LIBRARY}/${SPEC_ID} ($1, model=${MODEL}, change_request=none)"
fi
}
if [ -n "${INFRA_CAUSE}" ]; then
INFRA_ATTEMPT=$((INFRA_COUNT + 1))
if [ "${INFRA_COUNT}" -ge $((INFRA_WINDOW_CAP - 1)) ]; then
# Parked, not failed: nothing here says the pair is impossible.
# No `impl:<lib>:failed`, so the watchdog does not treat it as a
# capability verdict either; the next backfill dispatch picks it
# up with a clean budget once the markers age out.
echo "::warning::Parking $LIBRARY/$SPEC_ID after ${INFRA_ATTEMPT} infrastructure failures in the last ${CAMPAIGN_WINDOW_H}h (cap: ${INFRA_WINDOW_CAP}) — not marked failed, no auto-retry"
# One label per call: a name `gh` cannot resolve fails a whole
# comma-separated edit and would leave both labels in place.
for stale in "generate:${LIBRARY}" "impl:${LIBRARY}:pending"; do
gh issue edit "$ISSUE" --remove-label "$stale" 2>/dev/null || true
done
gh issue comment "$ISSUE" --body "${MARKER}
${INFRA_TAG}
## :pause_button: ${LIBRARY} Paused (infrastructure failures)
The **${LIBRARY}** implementation for \`${SPEC_ID}\` hit ${INFRA_ATTEMPT} infrastructure failures in the last ${CAMPAIGN_WINDOW_H}h (cap: ${INFRA_WINDOW_CAP}) and is paused. This is **not** a capability verdict — the pair keeps its 3-attempt budget.
**Last cause:** ${INFRA_CAUSE}
To retry once the incident is over:
\`\`\`
gh workflow run impl-generate.yml -f specification_id=${SPEC_ID} -f library=${LIBRARY} -f issue_number=${ISSUE} -f model=${MODEL}
\`\`\`
---
:robot: *[impl-generate](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
else
gh issue comment "$ISSUE" --body "${MARKER}
${INFRA_TAG}
## :cloud: ${LIBRARY} Infrastructure Failure (${INFRA_ATTEMPT}/${INFRA_WINDOW_CAP} in window)
**Cause:** ${INFRA_CAUSE}
Not counted toward the 3-attempt cap. Automatically retrying...
---
:robot: *[impl-generate](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
gh issue edit "$ISSUE" --remove-label "generate:${LIBRARY}" 2>/dev/null || true
dispatch_retry "infrastructure retry ${INFRA_ATTEMPT}/${INFRA_WINDOW_CAP}"
fi
exit 0
fi
# FAILURE_COUNT counts marker comments BEFORE this run.
# 0 → this is attempt 1 fail, 1 → attempt 2 fail, 2 → attempt 3 fail.
ATTEMPT=$((FAILURE_COUNT + 1))
# After 2 previous failures (= this is attempt 3) → mark as failed.
# Also entered when the failure count could not be determined
# (FAILURE_COUNT=999, fail-closed — see above).
if [ "$FAILURE_COUNT" -ge 2 ]; then
if [ "$FAILURE_COUNT" -eq 999 ]; then
CAP_REASON="the failure counter could not be read (failed closed — auto-retry disabled)"
else
# State the real total, not a flat "3 attempts": a pair capped by
# stale history had ONE attempt today, and reading the old wording
# as "tried three times, must be a capability gap" is exactly how
# recoverable pairs got written off (2026-08-24).
# ATTEMPT, not FAILURE_COUNT: the latter excludes the failure being
# handled right now, so at the cap it would under-report by one.
CAP_REASON="${ATTEMPT} failed attempt(s) in the last ${CAMPAIGN_WINDOW_H}h (cap: 3 per campaign)"
fi
echo "::warning::Marking $LIBRARY as failed: $CAP_REASON"
# Create failed label if needed
gh label create "impl:${LIBRARY}:failed" --color "d73a4a" \
--description "${LIBRARY} implementation failed" 2>/dev/null || true
# Add failed first, then drop the stale labels one per call: a
# name `gh` cannot resolve fails a whole comma-separated edit, and
# the old single call could leave the pair marked pending forever.
gh issue edit "$ISSUE" --add-label "impl:${LIBRARY}:failed" 2>/dev/null || true
for stale in "generate:${LIBRARY}" "impl:${LIBRARY}:pending"; do
gh issue edit "$ISSUE" --remove-label "$stale" 2>/dev/null || true
done
# Post final failure comment with marker
gh issue comment "$ISSUE" --body "${MARKER}
## :x: ${LIBRARY} Failed (retry cap reached)
The **${LIBRARY}** implementation for \`${SPEC_ID}\` was marked failed after ${CAP_REASON}.
**Reason:** Claude Code failed to create the implementation file.
To retry manually:
\`\`\`
gh workflow run impl-generate.yml -f specification_id=${SPEC_ID} -f library=${LIBRARY} -f issue_number=${ISSUE} -f model=${MODEL}
\`\`\`
---
:robot: *[impl-generate](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
else
# Attempt 1 or 2 failed → post comment with marker and auto-retry
gh issue comment "$ISSUE" --body "${MARKER}
## :warning: ${LIBRARY} Generation Failed (Attempt ${ATTEMPT}/3)
Attempt ${ATTEMPT} failed. Automatically retrying...
---
:robot: *[impl-generate](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
# Clean up generate label before retry
gh issue edit "$ISSUE" --remove-label "generate:${LIBRARY}" 2>/dev/null || true
dispatch_retry "attempt $((ATTEMPT + 1))"
fi