chore(audit): post-merge CI fix — ruff format + DebugPage auth tests … #5615
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "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 | ||
| issue_number: | ||
| description: "Issue number (optional, for tracking)" | ||
| required: false | ||
| type: string | ||
| # 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 | ||
| env: | ||
| # Library dependencies mapping | ||
| # Per-library deps now come from `pyproject.toml` `lib-{library}` extras — | ||
| # see [project.optional-dependencies] there. Keeping this list in sync was | ||
| # painful and silently drifted (this env block lagged the pyproject floors | ||
| # by a full major version on plotly/altair). Single source of truth wins. | ||
| 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 }}" | ||
| 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 }}" | ||
| 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 | ||
| # Language: only python supported today. Future multi-language work will derive this per spec. | ||
| LANGUAGE="python" | ||
| echo "specification_id=$SPEC_ID" >> $GITHUB_OUTPUT | ||
| echo "library=$LIBRARY" >> $GITHUB_OUTPUT | ||
| echo "language=$LANGUAGE" >> $GITHUB_OUTPUT | ||
| echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT | ||
| echo "::notice::Generating $LANGUAGE/$LIBRARY for $SPEC_ID (issue: ${ISSUE:-none})" | ||
| - name: Checkout repository | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | ||
| 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 | ||
| # ======================================================================== | ||
| - name: Set up Python | ||
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 | ||
| with: | ||
| python-version: '3.14' | ||
| - name: Install uv | ||
| uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 | ||
| - name: Install system dependencies | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y pngquant | ||
| - name: Install dependencies | ||
| 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: Setup Chrome for Highcharts | ||
| if: steps.inputs.outputs.library == 'highcharts' | ||
| uses: browser-actions/setup-chrome@4f8e94349a351df0f048634f25fec36c3c91eded # v2 | ||
| with: | ||
| chrome-version: stable | ||
| # ======================================================================== | ||
| # 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 }} | ||
| run: | | ||
| METADATA_FILE="plots/${SPEC_ID}/metadata/${LANGUAGE}/${LIBRARY}.yaml" | ||
| IMPL_FILE="plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}.py" | ||
| 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: Run Claude Code to generate implementation | ||
| id: claude | ||
| continue-on-error: true | ||
| timeout-minutes: 60 | ||
| uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1 | ||
| with: | ||
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | ||
| claude_args: "--model sonnet" | ||
| # 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 }} | ||
| - 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@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1 | ||
| with: | ||
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | ||
| claude_args: "--model sonnet" | ||
| # 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 }} | ||
| - 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 }} | ||
| ISSUE: ${{ steps.issue.outputs.number }} | ||
| BRANCH: ${{ steps.branch.outputs.branch }} | ||
| 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" | ||
| # Get Python version (e.g., "3.13.1" from "Python 3.13.1") | ||
| PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}') | ||
| # Get library version from pip (use explicit venv path) | ||
| get_pip_version() { | ||
| .venv/bin/pip show "$1" 2>/dev/null | grep -i "^Version:" | awk '{print $2}' | ||
| } | ||
| case "$LIBRARY" in | ||
| letsplot) | ||
| LIBRARY_VERSION=$(get_pip_version "lets-plot") | ||
| ;; | ||
| highcharts) | ||
| LIBRARY_VERSION=$(get_pip_version "highcharts-core") | ||
| ;; | ||
| *) | ||
| 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" ;; | ||
| highcharts) PYTHON_MODULE="highcharts_core" ;; | ||
| *) 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 | ||
| echo "::notice::Library version: $LIBRARY = $LIBRARY_VERSION" | ||
| # Interactive libraries additionally produce HTML previews (one per theme) | ||
| HAS_HTML="false" | ||
| case "$LIBRARY" in | ||
| plotly|bokeh|altair|highcharts|pygal|letsplot) HAS_HTML="true" ;; | ||
| esac | ||
| # 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') | ||
| py_ver = '$PYTHON_VERSION' | ||
| lib_ver = '$LIBRARY_VERSION' | ||
| has_html = '$HAS_HTML' == 'true' | ||
| metadata_file = '$METADATA_FILE' | ||
| 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 sonnet` actually runs: whatever | ||
| # Claude Code's current "sonnet" alias resolves to. | ||
| # Use the family name instead of a frozen version string so the | ||
| # metadata doesn't go stale every model release. | ||
| 'generated_by': 'claude-sonnet', | ||
| 'workflow_run': run_id, | ||
| 'issue': issue, | ||
| 'python_version': py_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}.py" | ||
| # 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}" | ||
| git push origin "$BRANCH" | ||
| 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 }} | ||
| 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}.py\`" | ||
| 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 | ||
| 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 | ||
| 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|highcharts|pygal|letsplot) | ||
| HTML_LIGHT="${BASE}/plot-light.html" | ||
| HTML_DARK="${BASE}/plot-dark.html" | ||
| ;; | ||
| esac | ||
| BODY="## :art: ${LANGUAGE}/${LIBRARY} Preview | ||
| | Light | 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 | ||
| if: steps.pr.outputs.pr_exists == 'true' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| PR_NUMBER: ${{ steps.pr.outputs.pr_number }} | ||
| 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" | ||
| echo "::notice::Triggered impl-review.yml via repository_dispatch for PR #$PR_NUMBER" | ||
| - 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 }} | ||
| run: | | ||
| echo "::notice::Handling generation failure for $LIBRARY/$SPEC_ID" | ||
| # 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 9 library impls land on the same issue). | ||
| MARKER="<!-- impl-fail:${SPEC_ID}:${LIBRARY} -->" | ||
| FAILURE_COUNT=$(gh api --paginate "repos/${{ github.repository }}/issues/${ISSUE}/comments?per_page=100" \ | ||
| --jq "[.[] | select(.body != null and (.body | contains(\"$MARKER\")))] | length" 2>/dev/null || echo "0") | ||
| echo "::notice::Previous failures for ${LIBRARY}/${SPEC_ID}: $FAILURE_COUNT" | ||
| # 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 | ||
| if [ "$FAILURE_COUNT" -ge 2 ]; then | ||
| echo "::warning::Marking $LIBRARY as failed after 3 generation attempts" | ||
| # Create failed label if needed | ||
| gh label create "impl:${LIBRARY}:failed" --color "d73a4a" \ | ||
| --description "${LIBRARY} implementation failed" 2>/dev/null || true | ||
| # Remove stale labels and add failed | ||
| gh issue edit "$ISSUE" \ | ||
| --remove-label "generate:${LIBRARY},impl:${LIBRARY}:pending" \ | ||
| --add-label "impl:${LIBRARY}:failed" 2>/dev/null || true | ||
| # Post final failure comment with marker | ||
| gh issue comment "$ISSUE" --body "${MARKER} | ||
| ## :x: ${LIBRARY} Failed (Attempt 3/3) | ||
| The **${LIBRARY}** implementation for \`${SPEC_ID}\` failed after 3 attempts. | ||
| **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} | ||
| \`\`\` | ||
| --- | ||
| :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 | ||
| # Automatic retry via workflow_dispatch | ||
| gh workflow run impl-generate.yml \ | ||
| -f specification_id="${SPEC_ID}" \ | ||
| -f library="${LIBRARY}" \ | ||
| -f issue_number="${ISSUE}" | ||
| echo "::notice::Triggered automatic retry for ${LIBRARY}/${SPEC_ID} (attempt $((ATTEMPT + 1)))" | ||
| fi | ||